技术文章

在 Delphi 中使用 HotPDF 从已加载的 PDF 中提取文本

HotPDF Component 通过两次调用即可在 Delphi 中从您加载的任何 PDF 中提取 Unicode 文本:ExtractLoadedPageText 返回页面的阅读流文本,而 ExtractLoadedPageTextLayout(在 v2.263.0 中新增)将页面的视觉排列重构为纯文本,从而在输出中保留栏、缩进和表格对齐。两者都适用于非 HotPDF 创建的文档,而这正是实际应用中最重要的情况:客户通过电子邮件发送给您的发票、扫描机构交付的报告、或是由没人说得清名字的软件生成的合同

实现这一点需要比这两者函数签名所暗示的更复杂的机制,因为 PDF 存储文本方式与文本文件完全不同。本文将逐步介绍这两种提取模式,然后揭秘底层的三个核心部分 — CMap 读取器、内容流解释器和字体解码回退链 — 因为了解映射的工作原理,是在面对垃圾输出时耸耸肩还是能进行诊断的关键区别

为什么提取文本比从文件中读取字符串还要难?

PDF 内容流记录的是字符代码(character code),而不是字符(character)。TjTJ 运算符(ISO 32000-1 §9.4.3)携带字节流,其含义完全取决于前面的 Tf 选择的字体:字节 0x41 在 WinAnsi 下可能是字母 A,在子集字体中可能是一个任意字形,或者在复合中日韩字体中是一个双字节 CID 的一半。ISO 32000-1 §9.10 将文本提取准确地定义为此解码问题 — 即使用字体字典提供的任何信息将每个代码映射回 Unicode — 且该标准明确规定,符合标准的文件并不被要求提供足够的信息来完成此项工作

最后一句话解释了您见过的所有“为什么从此 PDF 复制粘贴会产生乱码”的缺陷报告。如果生成器嵌入了没有 /ToUnicode 表的子集字体,它编写的文件虽然渲染完美,但提取出来的内容却是废话,因为“代码到字形”的映射存在,但“代码到 Unicode”的映射从未交付。因此,任何诚实的提取 API 都是一种尽力而为的回退链,而有意义的问题是这条链有多深

使用 ExtractLoadedPageText 进行阅读流提取

对于搜索索引、关键字匹配或将文本送入分析管道, ExtractLoadedPageText 是您想要的调用。其签名为 function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean — 页面索引从零开始,结果作为原生的 Delphi UnicodeString 抵达,并且当页面没有可读的内容流时,该函数返回 False 而不是抛出异常

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // AllText 现在保存了文档的阅读流文本
  finally
    Pdf.Free;
  end;
end;

输出中的换行符来自一个刻意简单的启发式规则:当字形的垂直原点移动超过当前字体大小的一半时 — 这是内容流中 TdT* 步骤的特征 — 就会插入一个换行符。解码器无法解析的字符会变成空格而不是消失,因此即使单个字形无法解析,词边界也能幸存。此模式不尝试做的是阅读顺序聚类或多栏检测:双栏页面会按内容流的顺序交错输出,这通常(但并不总是)视觉顺序

何时应当改用保留布局的提取?

只要位置承载了含义,ExtractLoadedPageTextLayout 就是正确的调用:表格、表单、代码列表,以及您打算进行 diff、grep 或按列解析的任何内容。它不是将字形扁平化为流,而是将它们聚类为基线,按 X 轴对每个基线进行排序,并在根据字形平均步进(advance)和字体大小估算的等宽字符网格上重现水平和垂直空白。同一基线上运行段之间的宽间隙会变成空格流;基线之间的大间隙会变成空行。其结果读起来就像页面的外观一样

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // 栏、缩进和表格对齐在字符网格上
  // 保留为空格和空行
end;

这两种模式共享解码机制的每一个字节,仅在如何排列解码后的字形上有所不同,因此选择哪种并不会损失忠实度。当只有文字重要时选择 ExtractLoadedPageText,当排列重要时选择 ExtractLoadedPageTextLayout。多栏阅读顺序检测对于两者来说仍然不在考虑范围内 — 双栏页面的网格渲染忠实地向您并排展示两个栏,这对于 diff 来说是完全正确的,但对于散文重排流则不然

HotPDF 如何将字符代码解码为 Unicode?

HotPDF Component 通过优先级排序的回退链解析每个字符代码:首先是字体的嵌入式 /ToUnicode CMap,然后是 /Encoding 条目(流或命名的 CMap),接着是 — 对于复合字体 — 针对诸如 Adobe-GB1、Adobe-CNS1、Adobe-Japan1 和 Adobe-KR 等字符集的 Adobe 标准 CMap 文件,最后是针对简单字体的内置 WinAnsi 和 MacRoman 表。无法给出答案的策略会默默降级到下一个,而不是抛出异常,而耗尽整条链的代码将解析为 0,以便调用者可以统计未解析的个数,而不是瞎猜

/ToUnicode CMap(ISO 32000-1 §9.10.3)排在第一位,因为它是生成器专门为提取而编写的映射。Adobe 标准 CMap 路径对于使用预定义 CMap(如 UniGB-UTF16-H)而不嵌入任何内容的 CJK 文档非常重要:HotPDF 将集合文件存放在其 resources\CMap 目录下,在运行时相对于可执行文件定位它们,并在每个进程中缓存每个解析后的映射 — 这点值得了解,因为其中最大的 Adobe-GB1 映射大约有 2 MB 的源文本,您肯定不想在每页都重新解析它。如果该目录不存在,解码器只需跳过磁盘支持的 CMap,并使用嵌入的表加上内置的编码。这是在 使用 HotPDF 进行复杂脚本文字整形中介绍的整形问题的读取端镜像,在写入时也会面临同样的代码与字形的区别

值得了解的两个 CMap 语法陷阱

CMap 文件看起来很容易解析,但事实并非如此,有两个细节构成了大多数首次尝试解析器失败的原因。第一,记录计数位于部分关键字之前:一个部分读取为 2 beginbfchar,而不是 beginbfchar 2。期望在关键字之后获取计数的解析器会将该数字消费为一个迷失的标记,然后发现每个部分都有零个条目。健壮的方法 — HotPDF 读取器采用的方法 — 是完全忽略计数并循环直到匹配的 endbfchar / endbfrange 关键字,这样还有一个好处就是可以容忍那些计数本身就写错了的真实文件

第二个陷阱是 bfcharbfrange 目标是 UTF-16BE 字符串,而不是整数。目标 <D83DDE00> 意味着 U+1F600 — 这是一个必须重组为一个码点的代理对 — 并且如果将这四个字节读取为大端整数,则在基本多语言平面之外的每个码点上都会产生无意义的值。PDF 中的表情符号(emoji)不再少见,因此跳过代理对重组的解码器会在用户实际拥有的文件上失败。HotPDF 首先将十六进制字面量解析为原始字节,然后重组 UTF-16BE 代码单元,这也涵盖了连字映射产生的多字符目标

使用 ExtractLoadedPageGlyphs 降至字形级别

这两个文本调用都建立在 ExtractLoadedPageGlyphs 之上,且底层的 THPDFGlyphArray 也可供您的代码使用。每个 THPDFGlyphRecord 都携带解析后的 Unicode 码点,以及原始字符代码、代码的字节宽度(1、2 或 4,由 CMap 的 codespacerange 决定)、活动的字体资源键和大小、用户空间 X and Y 原点以及水平步进。这足以构建单词边界检测、唯定位高亮显示或自定义布局算法,而无需您亲自触及内容流

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

统计 Unicode = 0 的记录,如上面那样,是在您信任下游文本之前衡量给定文档提取质量的可靠方法。字形记录还将每个字符锚定到内容流中的源操作数,这正是使得在同一基础上实现 HotPDF 的已加载文档文本搜索与替换成为可能的原因

哪些 PDF 不会交出它们的文本?

有些文件可以击败任何提取器,而检测它们比直接输出其结果更好。扫描文档是最明显的例子:只包含一个大图像的页面根本不包含任何文本运算符,因此提取会正确返回一个空字符串 — 解决方法是 OCR,而从加载的 PDF 中提取页面图像是该管道的第一步。没有 /ToUnicode 表的子集字体是更棘手的情况:如果 /Encoding 路径和标准 CMap 也一无所获,这些字形将解析为 0 并作为空格呈现在文本调用中。加密文档可以正常提取,前提是您在 LoadFromFile 重载中通过其密码加载它们,这样在解释器看到它们之前,流就已经被解密了

一个更窄的限制值得明确说明:解码链通过 HotPDF 的 Flate 路径读取 CMap 和内容流,因此如果字体的 ToUnicode 流使用异常过滤器,它将降级为下一种策略,而不是使页面处理失败。在实践中,FlateDecode 覆盖了过去二十年中生产的几乎所有内容,并且这种降级在设计上是静默的 — 您将获得文件允许的最佳文本,而不是抛出异常。此处解析字体字典的相同读取端对象机制也支持编辑已加载文档的元数据,因此文档摄取管道可以在一次处理中完成提取、检查和添加批注

文本提取、保留布局的渲染、字形级访问以及在其上构建的搜索与替换功能都是适用于 Delphi 和 C++Builder 的标准 HotPDF Component 的一部分 — 没有外部 DLL,没有操作系统文本服务,只有当奇怪的文件落在您的队列中时您可以逐步调试的 Object Pascal