Artículo técnico

OCR chino y multilingüe con RapidOCR en HotPDF y Delphi

HotPDF hace OCR en chino y multilingüe en Delphi a través de su adapter nativo de la DLL RapidOCR: THPDFRapidOCRDLLOptions.ForLanguage mapea un tag de idioma como 'zh-CN', 'zh-TW', 'ru' o 'ar' a un modelo de reconocimiento y un diccionario de caracteres a juego, y THotPDF.ApplyLoadedOCRTextLayer convierte las líneas reconocidas en una capa de texto Unicode invisible y buscable sobre páginas PDF escaneadas

Hacer funcionar una demo en escritura latina es la parte fácil. Las fallas interesantes empiezan cuando usted cambia a chino tradicional o ruso y la salida se convierte en un disparate seguro de sí mismo y bien formado, o cuando cada línea pierde en silencio su último carácter, o cuando una página árabe vuelve con sus cajas de texto en el orden equivocado. Ninguno de estos levanta una excepción por sí solo. Los presets de idioma agregados en HotPDF v2.775.0 existen sobre todo para cerrar esas brechas, y las cuatro trampas de abajo vale la pena entenderlas aunque jamás toque el código nativo, porque cada una explica un síntoma por el que de otro modo gastaría un día cazando

¿Cómo elige ForLanguage un modelo y un diccionario?

THPDFRapidOCRDLLOptions.ForLanguage resuelve un tag a uno de nueve perfiles y devuelve opciones que apuntan a <profile>/recognition.onnx y <profile>/dictionary.txt debajo de su directorio de modelos, conservando el detector compartido, el clasificador de ángulos opcional, y los defaults de threads, píxeles y timeout de THPDFRapidOCRDLLOptions.Default. El método pasa el tag a minúsculas, convierte underscores en guiones y recorta espacios a los costados, así que 'zh_TW', 'ZH-tw' y ' zh-tw ' todos aterrizan en el mismo perfil. Los alias son una lista explícita y no un match por prefijo: 'zh-Hant-TW' se acepta porque está listado, mientras que una variante regional arbitraria que no esté listada levanta EArgumentException antes de que se cargue cualquier modelo

Resolución de perfiles ForLanguage de HotPDF para THPDFRapidOCRDLLOptions: tags como zh_TW, ZH-tw y zh-TW se normalizan y comparan contra nueve perfiles listados, cada uno fijando un modelo de reconocimiento y un diccionario que siempre se asignan juntos, mientras que un tag no listado levanta EArgumentException antes de cargar cualquier modelo
un tag selecciona un par modelo-diccionario fijo; el detector, el clasificador y los presupuestos siguen compartidos, y un tag desconocido falla rápido en vez de cargar nada
PerfilIdiomasTags de ejemploModelo fijado
chChino simplificado e inglészh, zh-CN, zh-Hans, chi_simPP-OCRv4
chinese_chtChino tradicionalzh-TW, zh-HK, zh-Hant, chi_traPP-OCRv3
enInglésen, en-US, en-GB, engPP-OCRv4
latinFrancés, alemán, español, portugués, italiano, neerlandés, turcofr, de, es-419, pt-BR, trPP-OCRv3
japanJaponésja, ja-JP, jpnPP-OCRv4
koreanCoreanoko, ko-KR, korPP-OCRv4
cyrillicRuso, ucraniano, búlgaro, bielorrusoru, ru-RU, uk, bgPP-OCRv3
arabicÁrabe, persa, urduar, ar-SA, fa, urPP-OCRv4
devanagariHindi, marati, nepalíhi, mr, nePP-OCRv4

El adapter en sí jamás descarga nada. Usted provisiona los archivos una vez con el helper incluido, por ejemplo tools/Install-RapidOCRModels.ps1 -Destination C:/OCR/models -Language ch,chinese_cht,cyrillic (o -Language All para los nueve perfiles), y el helper coloca un detector y un clasificador compartidos en los nombres de archivo de la raíz que Default espera. Después de eso, un escaneo en chino simplificado se vuelve buscable con unas pocas líneas. El plomería del engine es la misma costura IHPDFOCREngine descrita en el artículo sobre la DLL RapidOCR in-process y su frontera de ABI, así que este se queda enfocado en los 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 y clasificador compartidos
  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
    // una lista de páginas vacía significa todas; las que ya tienen texto se saltan
    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;

Dos detalles de esa salida merecen una nota. El pipeline nativo devuelve un resultado por línea de texto detectada, no por palabra, así que AcceptedWordCount cuenta líneas aquí, y MinimumConfidence se compara contra la confianza media por carácter de la línea entera: una línea que promedia 0.45 se descarta como unidad. UniqueScalarCount reporta cuántos escalares Unicode distintos tuvo que mapear la capa de texto a su fuente y a su tabla ToUnicode, un sanity check útil de que el texto CJK de verdad llegó en vez de un puñado de fallbacks latinos. Mantenga viva la interfaz del engine entre documentos, porque la inicialización de modelos ocurre en la factory y es el paso caro

¿Por qué cambiar solo el modelo de reconocimiento produce basura?

Un modelo de reconocimiento CTC jamás emite caracteres, solo índices de clase, y el diccionario es lo único que convierte el índice 1,204 en un glifo. Cambie ch/recognition.onnx por cyrillic/recognition.onnx pero conserve el diccionario chino, y el modelo felizmente emitirá índices cirílicos válidos que el diccionario viejo traduce a caracteres Han al azar. El resultado parece texto, pasa la validación UTF-8, y es buscable para exactamente nada. Por eso ForLanguage siempre asigna RecognitionModel y CharacterDictionary juntos, y por qué las opciones armadas a mano jamás deberían cambiar uno sin el otro

El chequeo de seguridad obvio, comparar el tamaño del diccionario con el ancho de salida del modelo, es necesario pero no suficiente. Dos diccionarios pueden tener el mismo número de entradas en otro orden, y un off-by-one en el orden corre cada carácter un code point. HotPDF por eso chequea en dos etapas cuando la factory inicializa el modelo. Primero, el conteo de clases de salida debe igualar las entradas del diccionario más dos. Segundo, cuando el archivo ONNX incrusta una lista de metadatos character, cada entrada del diccionario se compara con ella en orden, y un desajuste hace fallar la inicialización con EInvalidOperation y un diagnóstico nativo en vez de producir basura plausible más tarde

El "más dos" sale del layout de clases. La clase 0 es el blank de CTC, las clases 1 a N son las líneas del diccionario en orden de archivo, y la clase final es un espacio. Algunos diccionarios también traen su propia entrada de espacio, y esa línea debe conservarse exactamente como está. Aquí es donde un Trim bien intencionado hace daño real: convierte una entrada de un solo espacio en un string vacío y corre o rompe la tabla. La única normalización segura es quitar un retorno de carro final, así un diccionario guardado con fines de línea CRLF carga correctamente, mientras que una marca de orden de bytes UTF-8, una línea vacía o una entrada que contenga un tab se rechazan. El boceto de abajo muestra el layout en Pascal; es código explicativo, no una API de HotPDF

Layout de la tabla de clases CTC de HotPDF para diccionarios RapidOCR: la clase 0 es el blank, las clases 1 a N son las líneas del diccionario en orden de archivo con cualquier entrada de espacio solitario conservada, y la clase final es un espacio, dando N más 2 clases de salida que la factory verifica contra el modelo, metadatos incluidos
el diccionario es lo único que convierte índices de clase en caracteres, así que su tamaño, su orden y su entrada de espacio se verifican antes de reconocer una sola página
// Solo ilustración: la tabla de clases que espera un reconocedor CTC
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 al final del archivo
  SetLength(Result, Last + 3);
  Result[0] := '';                               // clase 0: blank de 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: suelte solo el CR
    if (Entry = '') or (Pos(#9, Entry) > 0) then
      raise EArgumentException.Create('Invalid dictionary entry');
    Result[I + 1] := Entry;                      // nunca Trim: ' ' es una clase
  end;
  Result[Last + 2] := ' ';                       // clase final: espacio
  // Length(Result) debe igualar el conteo de clases de salida del modelo
end;

¿Qué hace en realidad el decodificado CTC greedy?

El decodificado CTC greedy toma la clase de mayor puntaje en cada time step, colapsa las repeticiones consecutivas en un carácter, y descarta la clase blank; el blank es lo que permite que letras genuinamente dobles sobrevivan. Un modelo de reconocimiento mira una línea de texto como una secuencia de tajadas verticales angostas, y por cada tajada, o time step, emite una probabilidad para cada clase. Una línea que contiene AA中 podría producir la secuencia argmax A A blank A 中 space. Colapsar los primeros dos steps A da una A, el blank la separa de la siguiente A, y el resultado es AA中 con el espacio final intacto. Sin la regla del blank, book y bok serían indistinguibles

Recorrido GreedyCTCDecode de HotPDF: seis time steps votan clases argmax A, A, blank, A, un carácter Han y espacio, las repeticiones consecutivas colapsan, el blank resetea la guardia de repetición así una letra genuinamente doble sobrevive, y tres bugs de frontera sueltan en silencio los espacios entre palabras, el último carácter o los caracteres dobles
el decoder son una docena de líneas y cada frontera importa: incluya la última clase, incluya el último step, y deje que solo un blank separe repeticiones

Como el decoder es solo una docena de líneas, es fácil equivocarse en las fronteras, y las fallas son silenciosas. Si el loop interno de argmax se detiene una clase antes, la clase espacio jamás puede ganar y cada línea vuelve sin espacios entre palabras, lo que destroza la búsqueda de frases en páginas inglesas y latinas. Si el loop externo se detiene un time step antes, el último carácter de cada línea desaparece, lo que para una línea corta puede ser un tercio del texto. Y si la guardia de repetición no la resetea un blank, caracteres dobles como ll o reduplicaciones chinas como 谢谢 colapsan en uno. El decoder de HotPDF incluye la última clase y el último time step, conserva las repeticiones separadas por blank, y además rechaza puntajes que no sean finitos o que caigan fuera de 0 a 1, y cualquier conteo de clases que no coincida con el diccionario. Aquí está la misma lógica como ilustración en Pascal

// Solo ilustración: decodificado CTC greedy con límites correctos.
// Scores guarda Steps * Classes probabilidades, una fila 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;                            // clase 0 es el blank de CTC
  for Step := 0 to Steps - 1 do             // incluya el último time step
  begin
    Best := 0;
    BestScore := Scores[Step * Classes];
    for C := 1 to Classes - 1 do            // incluya la última clase (el espacio)
      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;                       // un blank resetea la guardia de repetición
  end;
end;

El decodificado greedy no es la estrategia CTC más precisa disponible; beam search con un modelo de lenguaje puede arreglar algunas tajadas ambiguas. Para documentos impresos a 300 DPI el resultado greedy suele ser lo que el modelo tiene para ofrecer, y el decoder no es el lugar para compensar debilidades del modelo. El modelo latino PP-OCRv3, por ejemplo, puede leer ñ como n incluso en input limpio. HotPDF no lo disimula con reemplazos de caracteres de post-procesamiento, porque una tabla de sustitución que arregla el español rompe otra cosa, y un carácter equivocado en una capa buscable es peor que un miss honesto

¿Cómo ordena HotPDF las líneas de texto, incluido el árabe de derecha a izquierda?

HotPDF ordena las cajas de texto detectadas de arriba abajo, agrupa cajas en una fila cuando se solapan verticalmente al menos la mitad de la altura de la caja menor, y ordena cada fila de izquierda a derecha, o de derecha a izquierda cuando RightToLeft está activo; los caracteres dentro de cada línea reconocida jamás se invierten. El agrupamiento importa porque un detector a menudo parte una línea visual en varias cajas, por ejemplo una etiqueta y un valor separados por una brecha ancha, y un ordenamiento puro por coordenada superior los intercalaría con la línea vecina cada vez que sus topes difieran en un píxel o dos

El preset árabe asigna RightToLeft := True, lo que le dice a la DLL que ordene las cajas de cada fila por su borde derecho, desde el margen derecho hacia adentro. Ese es todo el efecto. El texto que el modelo devuelve para una línea ya está en orden lógico Unicode, el orden en que un lector de árabe lo lee y lo tipea, y ese es también el orden que la extracción de texto PDF y la búsqueda esperan. Invertir mecánicamente el string para que "se vea bien" en un debugger rompería la búsqueda, el copiado y el pegado, y los lectores de pantalla. El display bidireccional y el shaping de glifos son asunto del visor

Un engine sirve un perfil de idioma. No hay detección automática de escritura, así que un documento que mezcla escrituras necesita un engine por perfil, aplicado a las páginas que lo usan. Como ApplyLoadedOCRTextLayer recibe una lista de páginas explícita y confirma cada llamada como su propia transacción all-or-nothing, eso es directo

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

function CreateRapidEngine(const Tag: string): IHPDFOCREngine;
var
  Models: THPDFRapidOCRDLLOptions;
begin
  // levanta EArgumentException para un tag desconocido, antes de cargar modelo alguno
  Models := THPDFRapidOCRDLLOptions.ForLanguage(Tag);
  Models.MaxPixels := 33554432;          // espacio 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');  // perfil chinese_cht
  Arabic := CreateRapidEngine('ar-SA');   // perfil arabic, 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;

La línea de MaxPixels está ahí por algo. Las opciones de la DLL hacen default a 16,777,216 píxeles por request, lo que cubre A4 y US Letter a 300 DPI con holgura, pero una página A3 a 300 DPI ronda los 3508 por 4961 píxeles, cerca de 17.4 millones, y el request se rechaza por pasarse del presupuesto. Suba MaxPixels (el techo es 67,108,864) o baje el THPDFOCRTextLayerOptions.DPI para formatos grandes. El ordenamiento de derecha a izquierda usa el export opcional HPDFRapidOCRSetReadingDirection de la versión de ABI 1; el adapter solo lo exige cuando RightToLeft está activo, así que una DLL más vieja sigue sirviendo los idiomas de izquierda a derecha y falla en la creación del engine con un EArgumentException que nombra el export faltante para el árabe

¿Por qué los modelos OCR más nuevos fallan al cargar?

La DLL RapidOCR de HotPDF enlaza un ONNX Runtime estático 1.14, que no puede leer modelos guardados con la versión de IR 10 de ONNX, y exports más nuevos como los modelos PP-OCRv5 pueden exigir un runtime más nuevo que ese; tal modelo falla en la creación del engine con un diagnóstico nativo. Esa restricción es la razón por la que los paquetes de idioma están fijados a pares reconocedor y diccionario PP-OCRv3 y PP-OCRv4 específicos en vez de "el más reciente", y por qué la tabla de arriba mezcla las dos generaciones: cada par fijado es uno que carga y verifica bajo ese runtime

El instalador aplica la pareja. Cada archivo de su manifiesto lleva un hash SHA256, un archivo existente con un hash distinto detiene la instalación en vez de sobrescribirse, y cada descarga aterriza bajo un nombre temporal y solo se mueve a su lugar después de que su hash coincide. Eso protege contra la versión silenciosa del problema del diccionario: alguien deja a mano un recognition.onnx más nuevo en un folder de perfil, el conteo de clases casualmente coincide, y nada falla hasta que un cliente reporta que la búsqueda no encuentra palabras que se ven clarísimas. En runtime el adapter se queda offline y jamás va por un modelo faltante. El reconocedor además valida la forma del modelo al cargar, aceptando input NCHW con altura fija de 32 o 48 píxeles o altura dinámica, que corre a 48

Si necesita una escritura que ninguno de los nueve perfiles cubre, todavía puede apuntar RecognitionModel y CharacterDictionary a sus propios archivos. Los mismos chequeos aplican, que es justo el punto: un par desparejo falla en la inicialización, no en el archivo de su cliente. Para páginas donde ningún perfil de RapidOCR encaja, el adapter de Tesseract para PDF buscable se enchufa a la misma llamada ApplyLoadedOCRTextLayer, y para formularios ASCII de máquina el engine de OCR por matching de plantillas integrado no necesita modelos para nada

Referencia rápida: checklist de RapidOCR multilingüe

  • Cree las opciones con THPDFRapidOCRDLLOptions.ForLanguage y trate EArgumentException como un tag no soportado, no como una falla de runtime
  • Cambie RecognitionModel y CharacterDictionary juntos, jamás uno solo; conteos de clases iguales no prueban un orden de caracteres igual
  • Deje los diccionarios como UTF-8 sin BOM, nunca recorte entradas, y espere que el modelo tenga N + 2 clases: blank, N entradas, espacio
  • Un decoder CTC custom debe cubrir la última clase y el último time step, y conservar las repeticiones separadas por un blank
  • Use un engine por perfil de idioma y pase listas de páginas explícitas para documentos de escrituras mezcladas
  • RightToLeft cambia solo el orden de las cajas; el texto reconocido se queda en orden lógico Unicode
  • Instale los modelos con Install-RapidOCRModels.ps1 así los pines SHA256 sostienen la pareja modelo-diccionario; asigne UseAngleClassifier := False si instaló con -SkipClassifier
  • Suba MaxPixels por encima del default de 16,777,216 antes de correr páginas A3 o mayores a 300 DPI

Los presets de idioma RapidOCR, el adapter de la DLL nativa y el pipeline de la capa de texto OCR son parte del HotPDF Delphi PDF Component para Delphi, C++Builder y FPC/Lazarus para Windows, arrancando con v2.775.0 para los perfiles multilingües