技术文章

在 Delphi 中为扫描版 PDF 添加可搜索文本层

PDFium Component 通过 ApplyOcrSearchLayer 从 Delphi 为扫描版 PDF 页面添加可搜索文本层。它会渲染每一页选定的页面,把像素交给你自己提供的 OCR 引擎,再把识别出的单词以隐形文本对象的形式写回,并精确定位在扫描图中对应单词的上方。原始页面图像自始至终不会被解码、重新编码或替换,因此视觉效果与你开始时的页面逐字节一致

识别引擎有意不作为库的一部分。PDFium 暴露的是页面渲染、坐标映射、字体加载、文本对象创建和隐形渲染模式,但它本身不包含任何 OCR 引擎,假装并非如此就等于把别人的识别产品捆绑进一个 PDF 组件。识别功能因此位于 IPdfOcrProvider 接口之后:库传入固定布局、原点在顶部的 BGRA 像素,提供方返回 Unicode 文本、置信度值和单词四边形

可搜索文本层究竟是什么?

一份扫描版 PDF 是一张文档的图片。页面内容是一整张图像,没有任何东西可以选中、搜索、复制或索引。可搜索文本层会在这张图像之上叠加真正的文本对象,渲染模式设为隐形,因此查看器不会绘制任何多余的东西,而选中、搜索和提取都能精确找到单词实际所在的位置

定位是整件事的关键所在。如果隐形文本偏离哪怕几个点,选中高亮就会落在单词旁边而不是单词上,复制一段文字也会得到顺序错乱的文本。这正是为什么几何信息必须来自 PDFium 用来渲染页面的那同一套变换,而不是靠比例猜测

实现提供方接口

提供方契约只有一个方法。它接收一条页面图像记录,携带尺寸、行距、DPI、像素格式和像素字节本身,外加一个取消令牌,返回单词列表或一条错误信息:

uses
  PDFium;

type
  TMyOcrProvider = class(TInterfacedObject, IPdfOcrProvider)
  public
    function RecognizePage(const Image: TPdfOcrImage;
      const CancellationToken: IPdfCancellationToken;
      out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
  end;

function TMyOcrProvider.RecognizePage(const Image: TPdfOcrImage;
  const CancellationToken: IPdfCancellationToken;
  out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
var
  I: Integer;
begin
  // Image.Pixels 携带以 Image.Stride 字节为一行、原点在顶部的 BGRA 行数据。
  // 把它交给你自己的引擎,然后为每个识别出的单词填一条记录
  SetLength(Words, RecognisedCount);
  for I := 0 to RecognisedCount - 1 do
  begin
    Words[I].Text := EngineWordText(I);
    Words[I].Confidence := EngineWordConfidence(I);   // 0..1
    Words[I].Quad := TPdfOcrQuad.FromRectangle(
      EngineLeft(I), EngineTop(I), EngineRight(I), EngineBottom(I));
  end;
  ErrorMessage := '';
  Result := True;
end;

用四边形而不是矩形,是因为一份扫描件很少与页面完全对齐。稍有倾斜的页面上,一个单词占据的是一个平行四边形,TPdfOcrQuad 携带四个角点,才能让倾斜和旋转的单词保有准确的选中区域。只报告轴对齐矩形的引擎可以用 FromRectangle,它会构造出退化的四边形

为什么单词位置不能按比例缩放?

把一个像素坐标除以渲染宽度、再乘以页面宽度,以此转换成页面坐标,这种做法很有诱惑力。但它只在页面没有旋转、CropBox 与 MediaBox 完全一致、原点位于零点这三个条件都成立时才有效,而现实中大量扫描文档至少违反其中一条

PDFium Component 会通过 FPDF_DeviceToPage 逐个映射四边形的四个角点,这正是渲染器用来生成像素的同一套映射,因此 /Rotate 项和有偏移的裁剪框在这个过程中会被自然而然地正确处理。文本对象的仿射矩阵随后由三个映射后的点构造而成,即左下、右下和左上三个角点,这三个点恰好足以表达位置、缩放、旋转和错切

文本对象本身是以单位字号创建的,以便测出其真实字体边界,再把测得的对象边界映射到目标四边形上。如果靠猜一个字号并寄希望它与扫描单词匹配,结果会随着每次字体替换而漂移;先测量再定位,能让贴合效果不受文本层所用字体影响

在整份文档上运行

选项记录控制分辨率、过滤方式和每一项预算。置信度过滤比看上去更重要:低置信度的垃圾单词会永久污染搜索结果,而且不像渲染错误那样容易发现,往往要等到某次搜索返回一堆胡言乱语才有人注意到:

var
  Pdf: TPdf;
  Options: TPdfOcrOptions;
  Report: TPdfOcrReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'scanned-contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfOcrOptions.Default;
    Options.Dpi := 300;                  // 识别分辨率
    Options.MinConfidence := 0.60;       // 丢弃不确定的单词
    Options.SkipPagesWithText := True;   // 不动原生数字页面
    Options.ContinueOnError := True;     // 一页出错不能让整个任务停下
    Options.MaxPixelsPerPage := 40 * 1000 * 1000;

    if Pdf.ApplyOcrSearchLayer(TMyOcrProvider.Create, Options, Report) then
      Pdf.SaveAs('scanned-contract-searchable.pdf');

    for I := 0 to High(Report.Pages) do
      if Report.Pages[I].Status = popsFailed then
        Writeln(Format('page %d failed: %s',
          [Report.Pages[I].PageNumber, Report.Pages[I].ErrorMessage]));
    Writeln(Format('%d word(s) inserted, %d rejected, %d page(s) skipped',
      [Report.InsertedWordCount, Report.RejectedWordCount,
       Report.SkippedPageCount]));
  finally
    Pdf.Free;
  end;
end;

SkipPagesWithText 在混合档案中格外值得强调。一份已经带有真实文本的 PDF,无论是原生数字文档还是之前处理过的文档,若不加区分地对它跑一遍 OCR,就会得到第二个文本层,重复的内容会让提取操作把每个单词都返回两遍。每页的状态 popsSkippedExistingText 会准确告诉你哪些页面被跳过了

预算、取消和失败隔离

每一项可能被恶意或仅仅是超大文档撑爆的数量都设有上限:每页及总量的像素数、每页及总量的单词数、每个单词的字符数。所有这些都在写入页面之前而不是之后被检查,像素估算也是在分配任何位图之前根据页面尺寸和 DPI 计算出来的。DPI 从 150 提到 300 会让每页内存占用变为四倍,因此当批量任务在处理大幅面文档时开始失败,每页上限就是首先要调整的参数

取消令牌贯穿整条路径:渐进式渲染、提供方调用和逐词插入循环。这意味着用户在识别一份 400 页文件的过程中取消操作,只需一页的时间就能停下,而不用等到整份文档处理完;组件其他地方使用的同一套令牌机制,详见 可取消的渐进式渲染,在这里同样适用,无需改动

失败隔离是按页进行的。库会收集它在某一页上插入的对象句柄,并在所有单词都放置完毕后调用一次 FPDFPage_GenerateContent。如果中途出现任何失败,无论是提供方报错还是字体问题,该页上已插入的对象都会被按相反顺序移除,页面内容也会被重新生成,因此一页失败会让该页恢复到原始状态,而不是留下半成品文本层。文档级的循环随后会根据 ContinueOnError 继续或停止,当前活动页始终会被恢复

验证图像确实未被触碰

最有力的检查也是最简单的:在应用文本层前后以相同尺寸渲染该页面,比较两张位图。它们应当逐字节完全相同,因为隐形文本不会绘制任何东西,图像流也从未被解码过。任何差异都意味着有除文本层以外的东西改动了该页面

之后再验证文本这一侧,从处理后的文件中提取内容,确认单词位置落在扫描图上。提取路径与 从 PDF 文档提取文本 中描述的是同一条,若要快速目视核对对齐效果,像 将 PDF 页面转换为 JPEG 中那样把页面渲染成图像,就能把单词框叠加在扫描图上查看

OCR 文本层、渲染、提取和编辑都运行在 Delphi、C++Builder 和 Lazarus 中同一个文档对象上;完整 API 描述见 PDFium Component for Delphi 页面