技术文章

HotPDF 用 RapidOCR 做 Delphi 中文与多语言 OCR

HotPDF 通过原生 RapidOCR DLL 适配器在 Delphi 里做中文与多语言 OCR:THPDFRapidOCRDLLOptions.ForLanguage 把 'zh-CN'、'zh-TW'、'ru' 或 'ar' 这类语言标签映射到配套的识别模型与字符字典,THotPDF.ApplyLoadedOCRTextLayer 再把识别出的行变成扫描 PDF 页面上一层不可见、可搜索的 Unicode 文本

让拉丁字母的 demo 跑起来是容易的部分。有意思的失败从你切到繁体中文或俄语开始:输出变成一本正经、格式工整的胡话;或者每一行都无声丢掉最后一个字符;又或者阿拉伯语页面的文本框顺序不对。这些情形没有哪个会自己抛异常。HotPDF v2.775.0 加入的语言预设主要就是为了堵这些缺口,下面四个坑就算你永远不碰原生代码也值得搞懂——每一个都解释了一种症状,不然你能为它追一整天

ForLanguage 怎么选模型与字典?

THPDFRapidOCRDLLOptions.ForLanguage 把标签解析成九个配置之一,返回的选项指向模型目录下的 <profile>/recognition.onnx 和 <profile>/dictionary.txt,同时保留共享的检测器、可选的角度分类器,以及 THPDFRapidOCRDLLOptions.Default 的线程、像素与超时默认值。方法会把标签转小写、下划线换连字符、去掉首尾空白,所以 'zh_TW'、'ZH-tw' 和 ' zh-tw ' 都落在同一个配置上。别名是显式清单而非前缀匹配:'zh-Hant-TW' 被接受,因为它在清单里;一个没列出的地区变体则在任何模型加载之前就抛 EArgumentException

HotPDF 的 ForLanguage 配置解析(THPDFRapidOCRDLLOptions):zh_TW、ZH-tw、zh-TW 这类标签归一化后与九个列出的配置匹配,每个配置钉死一对永远同时设置的识别模型与字典,未列出的标签在任何模型加载前就抛 EArgumentException
一个标签选定一对钉死的模型与字典;检测器、分类器与各项预算保持共享,未知标签快速失败,什么都不加载
配置语言示例标签钉死的模型
ch简体中文与英文zh、zh-CN、zh-Hans、chi_simPP-OCRv4
chinese_cht繁体中文zh-TW、zh-HK、zh-Hant、chi_traPP-OCRv3
en英文en、en-US、en-GB、engPP-OCRv4
latin法语、德语、西班牙语、葡萄牙语、意大利语、荷兰语、土耳其语fr、de、es-419、pt-BR、trPP-OCRv3
japan日语ja、ja-JP、jpnPP-OCRv4
korean韩语ko、ko-KR、korPP-OCRv4
cyrillic俄语、乌克兰语、保加利亚语、白俄罗斯语ru、ru-RU、uk、bgPP-OCRv3
arabic阿拉伯语、波斯语、乌尔都语ar、ar-SA、fa、urPP-OCRv4
devanagari印地语、马拉地语、尼泊尔语hi、mr、nePP-OCRv4

适配器自己从不下载任何东西。文件用随附的辅助脚本一次性准备好,比如 tools/Install-RapidOCRModels.ps1 -Destination C:/OCR/models -Language ch,chinese_cht,cyrillic(或 -Language All 装全部九个配置),脚本会把共享检测器与分类器放到 Default 期望的根文件名上。之后,一份简体中文扫描件几行代码就能变得可搜索。引擎的管线就是 进程内 RapidOCR DLL 及其 ABI 边界一文描述的同一个 IHPDFOCREngine 接缝,所以本文专注于语言

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,共享检测器与分类器
  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
    // 空页面列表表示全部页面;已有文本的页面跳过
    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;

那两行输出有两个细节值得说明。原生流水线每个检测到的文本行返回一个结果,不是每个词一个,所以这里 AcceptedWordCount 数的是行,MinimumConfidence 对照的是整行的平均字符置信度:平均 0.45 的一行整个被丢。UniqueScalarCount 报告文本层为字体和 ToUnicode 表映射了多少个不同 Unicode 标量——一个不错的健全性检查,确认到手的真是 CJK 文本,而不是几个拉丁回退字符。引擎接口要跨文档保持存活,因为模型初始化发生在工厂里,是最贵的一步

为什么只换识别模型会产出垃圾?

CTC 识别模型从不输出字符,只输出类索引,把索引 1,204 变成一个字形的全靠字典。把 ch/recognition.onnx 换成 cyrillic/recognition.onnx 却留着中文字典,模型会高高兴兴发出合法的西里尔索引,旧字典把它们译成随机的汉字。结果看着像文本,通过 UTF-8 校验,可搜索的东西精确为零。所以 ForLanguage 总是同时设置 RecognitionModel 和 CharacterDictionary,手工构建的选项也绝不该只改一个

直觉上的安全检查——字典大小与模型输出宽度比对——必要但不充分。两本字典可以条目数相同而顺序不同,顺序上差一,每个字符就错位一个码点。所以工厂初始化模型时 HotPDF 分两阶段检查。第一,输出类数必须等于字典条目数加二。第二,ONNX 文件若内嵌 character 元数据列表,每个字典条目都与它按序比对,不匹配就以 EInvalidOperation 加原生诊断初始化失败,而不是晚些时候产出像模像样的垃圾

「加二」来自类布局。类 0 是 CTC blank,类 1 到 N 是按文件顺序的字典行,最后一个类是空格。有些字典还自带自己的空格条目,那一行必须原样保留。好心的 Trim 正是在这里闯祸:它把单个空格条目变成空字符串,挪动甚至弄坏整张表。唯一安全的归一化是去掉行尾的回车符,这样 CRLF 行尾保存的字典能正确加载;而 UTF-8 字节顺序标记、空行或含制表符的条目一律拒收。下面的示意代码用 Pascal 展示布局;它是解释性代码,不是 HotPDF API

HotPDF 的 RapidOCR 字典 CTC 类表布局:类 0 是 blank,类 1 到 N 是按文件顺序的字典行(单独的空格条目保留),最后一个类是空格,共 N 加 2 个输出类,工厂连元数据一起对照模型验证
字典是把类索引变成字符的唯一环节,所以在识别第一页之前,它的大小、顺序和空格条目都已验证
// 仅为示意: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);                                   // 文件末尾的换行
  SetLength(Result, Last + 3);
  Result[0] := '';                               // 类 0:CTC blank
  for I := 0 to Last do
  begin
    Entry := Lines[I];
    if (Entry <> '') and (Entry[Length(Entry)] = #13) then
      SetLength(Entry, Length(Entry) - 1);       // CRLF:只去掉 CR
    if (Entry = '') or (Pos(#9, Entry) > 0) then
      raise EArgumentException.Create('Invalid dictionary entry');
    Result[I + 1] := Entry;                      // 绝不 Trim:' ' 是一个类
  end;
  Result[Last + 2] := ' ';                       // 最后一个类:空格
  // Length(Result) 必须等于模型输出类数
end;

贪心 CTC 解码到底在做什么?

贪心 CTC 解码在每个时间步挑得分最高的类,把连续重复坍缩成一个字符,并丢掉 blank 类——正是 blank 让真正成双的字母得以幸存。识别模型把一个文本行看成一串窄窄的竖直切片,对每个切片(即时间步),它给每个类输出一个概率。含 AA中 的行可能产出 argmax 序列 A A blank A 中 space。前两个 A 步坍缩成一个 A,blank 把它与下一个 A 分开,结果就是 AA中 ,尾部空格完好。没有 blank 规则,book 和 bok 就无法区分

HotPDF 的 GreedyCTCDecode 演练:六个时间步投票 argmax 类 A、A、blank、A、一个汉字与空格,连续重复坍缩,blank 重置重复守卫让真正成双的字母幸存;三个边界 bug 会无声丢掉词间距、最后一个字符或成双字符
解码器只有十几行,每个边界都要命:包含最后一个类,包含最后一个时间步,重复之间只认 blank

解码器只有十几行,边界很容易写错,而且失败是无声的。内层 argmax 循环少跑一个类,space 类永远赢不了,每行回来都没有词间距——英语和拉丁页面的短语搜索就此报废。外层循环少跑一个时间步,每行的最后一个字符消失,短行上那就是三分之一的文本。重复守卫不被 blank 重置,ll 这样的成双字符或 谢谢 这类汉语叠词就坍缩成一个。HotPDF 的解码器包含最后一个类和最后一个时间步,保留被 blank 分隔的重复,还拒收非有限值或落在 0 到 1 之外的分数,以及任何与字典不匹配的类数。同样的逻辑写成 Pascal 示意如下

// 仅为示意:边界正确的贪心 CTC 解码。
// Scores 存 Steps * Classes 个概率,每个时间步一行
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;                            // 类 0 是 CTC blank
  for Step := 0 to Steps - 1 do             // 包含最后一个时间步
  begin
    Best := 0;
    BestScore := Scores[Step * Classes];
    for C := 1 to Classes - 1 do            // 包含最后一个类(空格)
      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;                       // blank 重置重复守卫
  end;
end;

贪心解码不是现有 CTC 策略里最准的;带语言模型的 beam search 能修掉一些含糊切片。对 300 DPI 的印刷文档,贪心结果通常就是模型能给的全部,解码器也不是补偿模型弱点的地方。比如拉丁 PP-OCRv3 模型,即使在干净输入上也会把 ñ 读成 n。HotPDF 不拿后处理字符替换来遮丑,因为修西语的一张替换表会弄坏别的东西,而可搜索层里一个错字比一次诚实的漏识别更糟

HotPDF 怎么给文本行排序,包括从右到左的阿拉伯语?

HotPDF 把检测到的文本框自上而下排序,垂直重叠达到较小框高一半以上的框归入同一行,行内从左到右排列——启用 RightToLeft 时则从右到左;每个识别出行内部的字符永不反转。分组之所以要紧,是因为检测器常把一个视觉行拆成几个框,比如被宽缝隔开的标签和数值,纯按顶坐标排序会在顶点相差一两像素时把它们与相邻行交错

阿拉伯语预设设置 RightToLeft := True,告诉 DLL 按右边缘给每行的框排序,从右边距往里。效果仅此而已。模型为一行返回的文本已经是 Unicode 逻辑序——阿拉伯读者阅读和输入的顺序——这也正是 PDF 文本提取与搜索期望的顺序。为了在调试器里“看着对”而机械反转字符串,会弄坏搜索、复制粘贴和读屏器。双向显示与字形整形是查看器的职责

一个引擎服务一个语言配置。没有自动文字种类检测,混合文字种类的文档需要每个配置一个引擎,各自应用在使用它的页面上。ApplyLoadedOCRTextLayer 接受显式页面列表,每次调用都是独立的全有或全无事务,所以这做起来很直接

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

function CreateRapidEngine(const Tag: string): IHPDFOCREngine;
var
  Models: THPDFRapidOCRDLLOptions;
begin
  // 未知标签会抛 EArgumentException,在任何模型加载之前
  Models := THPDFRapidOCRDLLOptions.ForLanguage(Tag);
  Models.MaxPixels := 33554432;          // 300 DPI 下 A3 页面的余量
  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 配置
  Arabic := CreateRapidEngine('ar-SA');   // 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;

MaxPixels 那行不是白写的。DLL 选项默认每次请求 16,777,216 像素,A4 和 US Letter 在 300 DPI 下绰绰有余,但 A3 在 300 DPI 下约 3508 × 4961 像素、约合 1,740 万,请求会以超预算被拒。大格式要么调高 MaxPixels(上限 67,108,864),要么调低 THPDFOCRTextLayerOptions.DPI。从右到左排序用的是 ABI 版本 1 的可选导出 HPDFRapidOCRSetReadingDirection;适配器只在设了 RightToLeft 时才要求它,所以旧 DLL 照样服务从左到右的语言,而阿拉伯语在引擎创建时就以 EArgumentException 失败、指名缺失的导出

为什么新的 OCR 模型加载失败?

HotPDF RapidOCR DLL 静态链接 ONNX Runtime 1.14,读不了以 ONNX IR 版本 10 保存的模型,而 PP-OCRv5 这类新导出可能要求比那更新的运行时;这类模型在引擎创建时带着原生诊断失败。正是这个约束让语言包钉死在特定的 PP-OCRv3 与 PP-OCRv4 识别器加字典对上,而不是「最新」,也是上表两代混排的原因:每个钉死的对都能在那个运行时下加载并通过验证

安装器强制配对。清单里每个文件带 SHA256 哈希,已有文件哈希不同就中止安装而不是覆盖,每个下载先落临时名,哈希匹配后才挪到正式位置。这防的是字典问题的安静版本:有人手工往配置文件夹里丢了个新的 recognition.onnx,类数恰好对上,于是直到客户报告「看得见的词搜不到」之前什么都不会失败。运行时适配器保持离线,从不抓取缺失的模型。识别器还在加载时验证模型形状,接受固定高度 32 或 48 像素或动态高度的 NCHW 输入,动态高度按 48 跑

九个配置都盖不住的文字种类,你仍可以把 RecognitionModel 和 CharacterDictionary 指到自己的文件。同样的检查照样适用——这正是意义所在:不匹配的对在初始化时失败,而不是在你客户的档案里。两个 RapidOCR 配置都不合适的页面,面向可搜索 PDF 的 Tesseract 适配器接进同一个 ApplyLoadedOCRTextLayer 调用;机器印刷的 ASCII 表单,内置模板匹配 OCR 引擎连模型都不需要

速查:多语言 RapidOCR 检查清单

  • 用 THPDFRapidOCRDLLOptions.ForLanguage 创建选项,把 EArgumentException 当作不支持的标签,而不是运行时故障
  • RecognitionModel 和 CharacterDictionary 一起换,绝不单换;类数相等证明不了字符顺序相等
  • 字典保持无 BOM 的 UTF-8,绝不 trim 条目,模型应有 N + 2 个类:blank、N 个条目、空格
  • 自定义 CTC 解码器必须覆盖最后一个类和最后一个时间步,并保留被 blank 分隔的重复
  • 每个语言配置一个引擎,混合文字种类的文档传显式页面列表
  • RightToLeft 只改框的顺序;识别出的文本保持 Unicode 逻辑序
  • 用 Install-RapidOCRModels.ps1 装模型,让 SHA256 钉住模型与字典的配对;用 -SkipClassifier 安装的话设 UseAngleClassifier := False
  • 300 DPI 跑 A3 或更大页面前,把 MaxPixels 调到默认值 16,777,216 之上

RapidOCR 语言预设、原生 DLL 适配器和 OCR 文本层流水线都是 HotPDF Delphi PDF Component 的一部分,面向 Delphi、C++Builder 与 Windows FPC/Lazarus,多语言配置自 v2.775.0 起提供