技术文章

用 HotPDF 在 Delphi 中以 Tesseract OCR 生成可搜索 PDF

HotPDF 经由 HPDFCreateTesseractOCREngine 用 Tesseract 把扫描的 PDF 页面变成可搜索 PDF——这个工厂把本地安装的 Tesseract 可执行文件包装成一个 IHPDFOCREngine。把引擎传给 ApplyLoadedOCRTextLayer,它渲染每一页、每页跑一次 Tesseract、解析其词级 TSV 输出,然后以一个事务为所有请求的页面提交不可见 Unicode 文本层——要么全部、要么全不

HotPDF 每页的 OCR 管线:按配置的 DPI 渲染页面,把 input.bmp 存进私有的 HotPDF-OCR 目录,带 tessedit_create_tsv 启动 Tesseract 子进程,解析十二列 TSV,按置信度过滤词,然后为所有请求的页面提交不可见文本层——或一页也不提交
适配器只替换识别环节:渲染、解析、校验和全有或全无的提交都留在既有的文本层管线里,下游代码不用改

这个适配器存在的理由是范围。内置模板匹配 OCR 引擎刻意收得窄:只认机打的 ASCII 字母和数字,别的都不认。带重音人名的发票、中文合同、多语言档案需要一个带训练语言模型的真识别器,而 Tesseract 是显然的候选——它是个命令行程序,可以部署在应用旁边。从文档库调用外部程序听起来简单,其实不然,适配器里大部分有意思的代码都在处理程序行为不端、挂死、被取消、或继承了不该看到的东西时发生的事

HotPDF 怎么从 Delphi 应用驱动 Tesseract?

HotPDF 每页把 Tesseract 当隐藏子进程运行,喂它一张渲染出的位图、读回一个 TSV 文件,并经与内置引擎相同的 IHPDFOCREngine 接缝暴露结果。下游什么都不用改:坐标映射、旋转处理、Unicode 校验、置信度过滤和原子提交都是你已有的文本层管线。工厂在 HPDFTesseractRecognition 单元里,并且急切校验:可执行文件必须存在,tessdata 目录必须存在,超时必须在 1 到 3,600,000 毫秒之间,语言标识只能含 ASCII 字母、数字、_ 和 +。最后这条检查要紧,因为语言字符串最终会出现在命令行上,eng+chi_sim 是合法的 Tesseract 值,而任何带引号或空格的东西都不是

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // 缺可执行文件、缺 tessdata、语言标识非法或超时不在 1..3600000 ms
  // 之内时抛 EArgumentException
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // 多个模型用 '+' 连接
    120000);            // 每页上限,默认 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;
    // 空页面列表表示每一页;默认跳过已有文字的页面
    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;

对每一页,Recognize 在临时路径下创建名为 HotPDF-OCR-{GUID} 的私有目录,把渲染出的位图存成 input.bmp,然后启动 tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1,每个路径实参都按 Windows 命令行的反斜杠与内嵌引号转义规则加引号。--dpi 的值来自 THPDFOCRTextLayerOptions.DPI 的渲染 DPI,所以 Tesseract 不必从图像元数据猜分辨率;--psm 3 要求全自动页面分割。引擎自报为 Tesseract (local CLI),这就是落进 Info.EngineName 的东西。Tesseract 及其语言模型不随 HotPDF 捆绑;安装它们是应用自己的事

TSV 解析器为什么这么严格?

HotPDF 的 TSV 解析器在任何一行畸形数据上就让整页失败,因为部分解析出的词表会产生一个与图像悄悄不一致的文本层。Tesseract 的 TSV 输出有固定的十二列表头,从 level 到 text,HotPDF 在剥掉可选字节顺序标记后把第一行与这个精确表头比较。后面每一行都必须恰好拆成十二个字段,拆分在第十一个 tab 之后停止,这样识别文本里的 tab 保持为词的一部分、而不是造出第十三列。只有 level 5 行是词;level 1 到 4 描述页、块、段落和行,被跳过。level 5 行文本为空或纯空白的也跳过,因为空白词有框但无可定位、可搜索的东西。其他一切都硬性检查:整数几何、用不变式 en-US 格式解析的置信度(免得德语 locale 把 93.5 读成乱码)、完全落在位图内的框、以及 0 到 100 之间的置信度。任何一处失败就抛异常,引擎返回 False,词数组清空。回归测试里恰好有这个用例:一个合法词后面跟着一行坏的,必须得到零个词、而不是一个

HotPDF 中每行 Tesseract TSV 都要过的六道闸:精确的十二列表头、恰好十二个字段、只认 level 5、非空白文本、框在位图之内、置信度 0 到 100 且按不变式解析——一行坏数据就让整页失败、归零
部分解析出的词表会与图像悄悄不一致,所以解析器在第一行畸形数据上就拒绝整页,而不是留住已读到的词
// 摘自 HPDFLocalTSVRecognition 的 level-5 循环
if (Fields.Count <> 12) or not TryStrToInt(Fields[0], Level) then
  raise EConvertError.Create('Invalid Local OCR TSV row');
if Level <> 5 then Continue;                 // 页/块/段落/行
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // 空白词没有位置
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;  // 管线期望 0..1

最后那行和一个你可能想不到的默认值有交互。Tesseract 置信度从 0 到 100,管线工作在 0 到 1,THPDFOCRTextLayerOptions.MinimumConfidence 默认 0.5,于是任何低于 50 的 Tesseract 词都被计入 Info.DroppedWordCount、永远到不了页面。干净的 300 DPI 扫描件上这是个合理的地板。嘈杂的传真件上它可能丢掉页面里惊人的一部分,正确做法是先看丢弃数再降阈值,因为低置信度词恰恰是最可能错的那些

Tesseract 子进程继承了什么?

Tesseract 子进程从 HotPDF 恰好继承两个句柄:用于标准输入和输出的 NUL 句柄,以及标准错误的文件句柄。这种精确就是要点。带 bInheritHandles = True 的 CreateProcess 是把标准句柄传给子进程的方式,但它自己会把宿主进程里每个可继承句柄都传过去——包括你应用里无关代码打开的文件、管道和事件。子进程随后把这些对象一直保活到自己退出,于是 Tesseract 磨一页的工夫里,一个文件一直被锁着、一根管道永远等不到尽头。HotPDF 用扩展启动记录堵上这个缺口:STARTUPINFOEX、一个携带 PROC_THREAD_ATTRIBUTE_HANDLE_LIST 的属性列表,以及 EXTENDED_STARTUPINFO_PRESENT 创建标志。句柄列表就位后,bInheritHandles 仍须为 True,但只有列出的句柄跨越边界。同样的围栏思路驱动着 在工作进程中隔离 PDF 图像编解码器——那边子进程是不可信代码;这边子进程可信,但宿主并不是自己句柄表的唯一主人

HotPDF 中 Tesseract 子进程的句柄继承:裸 CreateProcess 加 bInheritHandles 会把每个可继承的文件、管道和事件句柄传给子进程,而 STARTUPINFOEX 加 PROC_THREAD_ATTRIBUTE_HANDLE_LIST 把集合限制为一个 NUL 句柄(stdin 与 stdout)加 stderr 文件句柄
没有属性列表时,子进程把无关对象保活到自己退出,锁住文件、饿死管道;有了它,只有列出的两个句柄跨越边界
// 常量按名字展示;源码里传的是它们的数值
// 两个句柄都以 bInheritHandle = True 创建
InheritedHandles[0] := NullHandle;    // stdin 和 stdout
InheritedHandles[1] := ErrorHandle;   // 私有目录里的 stderr.txt
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,                                        // 句柄列表要求如此
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

为什么被取消的 OCR 运行看起来像引擎故障?

因为 IHPDFOCREngine.Recognize 只返回一个布尔值,而 False 同时意味着「Tesseract 失败了」和「用户按了取消」。子进程运行期间,适配器每 25 毫秒轮询一次取消令牌和超时,令牌触发时它在 Recognize 内部抛异常、捕获自己的异常、清理、然后带着诊断返回 False。如果管线把这当引擎错误,调用者会为一个用户主动停掉的任务看到 otlsEngineError。所以 ApplyLoadedOCRTextLayer 在 Recognize 返回 False 时先查令牌,只有令牌未置位才把结果转成引擎故障。这个顺序保住了多页契约:识别、校验、预算核算和内容构建对每个请求页面都跑完之后,图事务才打开,所以 50 页里的第 40 页被取消时报告 otlsCancelled、文档——包括前 39 页——原封不动。没有半可搜索的文件需要事后解释,其余失败处理遵循同样的有界风格:

  • 超时按每次 Recognize 调用计、从它开始时算,所以默认的 60,000 ms 作用于每一页而不是整个文档
  • 超时或取消时仍在运行的子进程被终止、最多等待 5 秒,其私有目录在 finally 块里删除
  • output.tsv 封顶 64 MiB,stderr.txt 封顶 1 MiB,子进程运行中和退出后都检查
  • 每页的词数与 UTF-16 码元按剩余的 MaxWordsPerPage、MaxTotalWords 和 MaxTextCodeUnits 预算封顶,超限让整次运行失败而不是截断词表
  • 标准输出进 NUL,因为 Tesseract 写的是 output.tsv;标准错误进文件,于是非零退出码会附带引擎自己的抱怨、最多 4,096 字符——这通常是发现缺了某个 .traineddata 文件的最快途径

识别出的词怎么变成不可见文本层

HotPDF 用文本渲染模式 3 把 Tesseract 的词写成不可见文本——ISO 32000-1 §9.3.6 定义的既不填充也不描边模式——于是页面仍显示扫描图像,而搜索和复制作用在识别出的词上。内容流以 3 Tr 打开 BT,每个词拿到一个落在其基线上的 Tm 矩阵、一个由框高像素在渲染 DPI 下换算出的字号,以及一个把字形串拉伸到实测框宽的 Tz 水平缩放——这就是搜索高亮会落在图像中那个词上、而不是飘过它的原因

Tesseract 的 TSV 有框但没有基线,所以适配器报告每个词都无基线,管线把基线估计在底边上方五分之一框高处。文本本身经过一个共享的未嵌入 Type0 字体、Identity-H 编码和生成的 ToUnicode CMap——整个运行里每个不同 Unicode 标量一个 CID——中文、带重音的拉丁文和增补平面字符因此在复制和搜索中都活下来。这个设计有两个值得先说清的限制:一次运行最多携带 65,535 个不同标量,而且未嵌入字体不满足 ISO 19005 的字体嵌入要求,所以 PDF/A 输出需要另行嵌入一个合规字体。验证结果很简单、也值得自动化:保存、重载、然后跑从已加载 PDF 提取文本那条普通的已加载文档文本路径;词从预期的页面回来了,层就是真的

同一 TSV 协议上的 RapidOCR 与其他引擎

HotPDF 经 HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) 为 RapidOCR 复用同一个进程运行器和 TSV 解析器——对简体中文扫描件这是更好用的选择。命令行除桥接脚本路径插在 Python 可执行文件之后外完全一样,语言固定为 chi_sim。HotPDF 以 tools/OCR/rapidocr_tsv.py 随附桥接脚本;它要求 rapidocr 和 onnxruntime 包加三个本地 ONNX 模型,禁用自动模型下载,并写出 Tesseract 形状的 TSV,于是 Delphi 侧不需要第二个解析器。Info.EngineName 里报告的引擎名是 RapidOCR (local ONNX)。这个形状暗示了一般配方:任何识别器,只要你能用一个小脚本包起来、接受 Tesseract 风格的实参列表、吐出十二列 TSV,就免费继承句柄隔离、超时、取消、输出预算和全有或全无的提交。适配器仅限 Windows,一次同步跑一页,除渲染器产出之外不做去斜或预处理,所以进去的图像质量仍然是出来的东西的上限

Tesseract 与 RapidOCR 适配器、不可见文本层写入器、喂它们的页面渲染器,以及验证结果的文本提取,都随同一个面向 Delphi 与 C++Builder 的原生 VCL 组件发布。如果你在给文档采集或归档应用加 OCR,HotPDF Delphi PDF component 给你整条管线,只剩 OCR 引擎本身需要安装