技术文章

Delphi PDF 文本、图像与字体提取

把文本、图像和字体从一份既有 PDF 里抽出来,听上去像是已经解决的问题,直到你真的拿一批真实语料跑一遍。让搜索索引器去啃四万份客户文件,坏掉的地方会归成几堆认得出来的类型。词与词粘在一起,因为没人告诉提取器多宽的间隙才算一个空格。另一些页面回来是乱码,因为一个子集化字体没有携带从它的字形码到实际字符的映射。而「公司 logo」到头来是九个各自独立的图像对象叠在一张软蒙版后面。这些都不是库的缺陷。这是「调用一个提取函数」与「弄明白这个函数能从磁盘上的字节里恢复出什么、恢复不出什么」之间的差别

losLab PDF Library 的 Pascal 版本,为 Delphi 和 C++Builder 代码提供了不止一种方式去读这三条流,而各档次在保证什么上并不相同。诀窍在于把档次与任务对上:搜索索引、编校审阅和 PDF/A 预检想从同一页里得到的东西各不相同,而抓错调用要么白费力气,要么产出你没法信任的结果

各档文本提取,以及每一档承诺了什么

GetPageText 接受一个 0 到 8 的选项值,而这个数字挑的是引擎而不是格式。0 到 2 走一遍轻量处理,对快速预览来说够用。3 到 8 走版面感知引擎,它依据字形在页面上的实际落点来重建行与间距。在这一段区间内部差异也很要紧:4 和 6 把输出切成词,5 和 6 输出逐字形宽度,而 7 返回纯文本,字体、颜色和块级元数据被刻意丢掉。喂给搜索索引的应该是选项 7,因为索引只要词,别的都不要

任何选项设置都救不了一份从一开始就没携带那份信息的文档。PDF 把字符码映射到字形形状,而唯一能把这些码映射回可读文本的,是字体的 ToUnicode CMap(ISO 32000-1 §9.10)。当一个子集化字体发布时不带它,任何提取器都束手无策。这个库、查看器里的复制粘贴、竞品工具包,全都只能靠字形名去猜,或者干脆什么都不返回。务实的应对是检测,而不是逞英雄。把这一页标为低置信度并送去 OCR,因为悄悄把垃圾索引进去,比承认自己读不了更糟

Delphi PDF 文本提取档次示意图:GetPageText 的选项 0 到 8 分别路由到轻量处理或版面感知引擎,而子集化字体缺少 ToUnicode CMap 的页面回退到 OCR
GetPageText 的选项值 0 到 8 在轻量预览处理与版面感知引擎之间做选择,其中选项 7 留给搜索索引,而缺失 ToUnicode CMap 的页面被分流到 OCR

对于扁平选项覆盖不到的场景,比如自定义分词、内容流取证、按你自己规则搭的文本漏斗,下面一层的解码器是可用的。TPDFExtractor 构造在某一页的资源字典和字体集合之上。它的 ExtractTextW 方法把原始内容流的文本操作重新送回同一套字体机制里去恢复 Unicode,而它的 OnFindObject 事件会在对象流过时把每一个交到你手上。多数代码永远不需要探到这么深。真需要的那些应用,会庆幸这一层是公开的而不是被埋起来的

带位置的文本块:搜索命中与编校审阅的基本单位

纯文本告诉你这一页说了什么。迟早产品还需要知道它说在哪里,好去高亮一处搜索命中、给一个编校候选画个框,或者把注释锚定到正确的位置上。ExtractPageTextBlocks 返回一个文本段列表的句柄,每一段都携带它的文本、它的边界框,以及它所使用的字体名与字号:

var
  Pdf: TPDFlib;
  Blocks, I: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('contract.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    Pdf.SelectPage(1);
    Blocks := Pdf.ExtractPageTextBlocks(0);
    for I := 0 to Pdf.GetTextBlockCount(Blocks) - 1 do
      Writeln(Format('%s  [%s %.1f pt at %.0f,%.0f]',
        [Pdf.GetTextBlockText(Blocks, I),
         Pdf.GetTextBlockFontName(Blocks, I),
         Pdf.GetTextBlockFontSize(Blocks, I),
         Pdf.GetTextBlockBound(Blocks, I, 0),
         Pdf.GetTextBlockBound(Blocks, I, 1)]));
    Pdf.ReleaseTextBlocks(Blocks);
  finally
    Pdf.Free;
  end;
end;

这一带有个细节绊倒的集成比任何其他细节都多。SetTextExtractionAreaSetTextExtractionWordGapSetTextExtractionOptions 是会一直保留的文档级状态,不是你按次调用传进去的实参。为某个功能配一次区域限制,比如只读页眉带来给文档分类,它就会悄悄截断此后在同一个句柄上的每一次提取,包括你之后才去用的那些版面感知 GetPageText 档次。要么在逻辑任务之间重置提取状态,要么给每个任务配它自己的文档句柄

词间隙阈值是应对第一堆故障,也就是词粘在一起的那个把手。SetTextExtractionWordGap 告诉版面引擎,相对于页面自身的字形间距,多大的水平空白才把一个词与下一个词分开。密集表格需要的间隙比排版疏朗的营销页更小,因此按文档类别调好的阈值胜过一个全局常量。它像其余提取状态一样在文档上保留,所以请打算好刻意去设它,而不是设一次就忘

示意图:Delphi 中的文档级 PDF 提取状态在同一句柄上的多次调用之间保留,直到被重置,从而避免后续提取被悄悄截断
提取区域、词间隙和选项设置都保留在文档句柄上,因此为某个功能圈定的区域会悄悄截断此后每一次提取,直到状态被重置或换掉句柄

图像:原始流,而不是截图

从 PDF 里取图像的错误做法,是渲染页面再裁剪。那会对像素重采样、把任何旋转烘焙进去,并丢掉原件本来的样子。GetPageImageList 则枚举页面实际引用的那些图像资源,每一项交还它的属性和它原始的、未经扰动的数据:

var
  ImgList, I: Integer;
begin
  Pdf.SelectPage(1);
  ImgList := Pdf.GetPageImageList(0);
  for I := 0 to Pdf.GetImageListCount(ImgList) - 1 do
  begin
    Writeln(Pdf.GetImageListItemFormatDesc(ImgList, I, 0));
    Pdf.SaveImageListItemDataToFile(ImgList, I, 0,
      Format('page1-img%.2d.bin', [I]));
  end;
  Pdf.ReleaseImageList(ImgList);
end;

在对某一项作出任何假定之前,先查 GetImageListItemFormatDesc,因为一个页面所引用的东西,很少是每张可见图像对应一份齐整的图片。软蒙版会作为独立的一项出现。同一个 XObject 常常在多页之间重复,所以在归档一份「全部图像」导出之前先按内容哈希去重,否则你会把同一个 logo 写上一百遍。CMYK JPEG 需要在下游做色彩管理,否则在那些照单全收通道值的查看器里会显示成反色。当你想要的是全文档清单而不是逐页处理时,FindImages 配合 SetFindImagesMode 一趟就能扫完整个文件

有一条边界值得在任何人写下验收标准之前先跟干系人讲明:图像提取只返回光栅资源。一个以矢量路径绘制的 logo 或图表,在资源意义上不是图像,无论它在屏幕上看起来多像一张图片,都永远不会出现在任何图像列表里。当需求真的是把那张图表作为文件交付时,诚实的做法是把页面区域渲染成位图,那是另一种保真度不同的操作。这两类输出如果不加标签说明谁是谁,就不该放进同一个导出文件夹

对比:在 Delphi 中渲染 PDF 页面来取图,与用 GetPageImageList 提取原始图像流,并附软蒙版、重复 XObject 与 CMYK 的注意事项
渲染再裁剪会对像素重采样并丢弃原始图像数据,而 GetPageImageList 枚举的是已存储的图像资源,连同它们的属性和未经扰动的流

字体:一个审计面,而不是导出功能

字体 API 回答的是关于字体的问题。它不会把字体文件本身交给你,而这个区分塑造了你能在它之上构建的一切。在 FindFonts 扫过文档之后,枚举按 ID 遍历各字体,而属性调用报告的是当前选中的那个字体:

var
  I: Integer;
begin
  Pdf.FindFonts;
  for I := 1 to Pdf.FontCount do        // 字体索引从 1 开始,不是从 0
    if Pdf.SelectFont(Pdf.GetFontID(I)) = 1 then
      Writeln(Format('%s  type=%d  embedded=%d  subset=%d',
        [Pdf.FontName, Pdf.FontType,
         Pdf.GetFontIsEmbedded, Pdf.GetFontIsSubsetted]));
end;

盯住循环边界。字体索引从 1 跑到 FontCount,而上面几段里的文本块索引和图像列表索引是从零开始的。把一种约定带到另一处,你就得到一个差一错误,它要么跳过第一个字体,要么跑出末尾,而且它能通过随手做的测试,因为多数文档有好几个字体,取错的那个看上去也还算说得通。范围也要说清楚。这套 API 没有字节级的字体导出。没有任何调用会把嵌入字体程序作为 TTF 或 OTF 文件返回,枚举加元数据检视就是全部的设计模型。这个模型仍然覆盖了生产工作真正会向字体提出的要求:按名称模式检测子集、在归档转换之前做嵌入审计(未嵌入字体是 PDF/A 的硬性阻断项,Delphi 中的 PDF/A 与 PDF/UA 预检对此有展开),以及在提取置信度下降时做编码诊断。这条边界画在这里还有一个授权方面的理由。一个子集字体程序是受授权的材料,而且缺了大部分字形,本来也没法当成可安装字体用。把它当作审计元数据而不是可提取的资产,是站得住脚的立场

最后那个调用在分诊环节很顶用。对每个字体跑一遍 GetFontEncoding,把它与子集标志一起读,你就能在抽出任何一个字符之前先预测提取质量。一个字体全部子集化且编码非标准的页面,光看这一点就是 OCR 候选,这让批处理流水线能正确分流它,而不必先在它身上浪费一次注定失败的提取

不加载文档也能做规模化提取

在批处理流水线里,为了读一页而加载整份文档是浪费 I/O,跨语料一累加就很可观。单次调用的变体 ExtractFilePageTextExtractFilePageTextBlocks 直接接受文件名、口令和页码,跳过完整加载。对 GB 量级的文件还有更低的一挡。直接访问路径通过流式读取 xref 来打开文件,于是 DAOpenFileReadOnly 后面跟一个 DAExtractPageText,只会触碰那一页真正需要的对象。它带来一处值得记牢的约定切换:DA 函数按 PageRef 寻址页面,那是一个你从 DAFindPage 拿到的对象引用句柄,绝不是原始页码。把数字传到该放句柄的位置上,调用会作用于错误的对象而不报任何错误,而这是最难调试的那种错误。直接访问工具箱的其余部分陈列在大型 PDF 的合并、拆分与直接访问

如果说有一个习惯把能扛住真实语料的提取代码和一瘸一拐的代码区分开来,那就是把页面当作不可信输入,而不是干净的数据源。与查看器渲染结果不符的文本,几乎总是编码问题,或是一个连字坍缩成了单个字形,或是一个子集字体缺了它的 ToUnicode 条目,而修法是去度量置信度并把坏页分流到 OCR,不是跟这些字节死磕。字体 API 按设计永远不会产出 TTF 或 OTF,所以请围绕审计问题去搭字体工作流。而那些持久的提取状态,尤其是区域矩形,是你在一个文档句柄的整个生命周期里都拥有的设置,不是一次调用之后就忘掉的参数。把这三条反射练对了,API 的其余部分自会规规矩矩

评估版构建、演示工程和完整的提取 API 参考位于 losLab PDF Library for Delphi 产品页