技术文章

Delphi 中的 PDF 文本提取:词间空格与换行

HotPDF Delphi Component 在 THotPDF.ExtractLoadedPageText 里从字形几何重建词间空格和换行,而不是靠空格字符。一个字形自身宽度之后的间隙超过文本高度的 0.15 时插入一个空格;文本原点沿书写方向移动超过文本高度的一半时才开新行。从 v2.768.3 起,页面文本还包含经 Form XObject 画出的文字,并把可见裁剪区之外的字形排除在外。这篇剩下的部分解释每条规则为什么长这样,因为每一条都替换过一条更简单的规则——那些规则在真实文档上产出看似合理实则错误的结果

把 PDF 文本喂给搜索索引的人对这些症状都不陌生。封面页提取成 PDFReferenceManualNovember4,1998,一张税表碎成 156 行,一条对角水印一行一个字,一份裁切过的打样开头带着阅读器从不显示的印前标记行。这些文件没一个坏掉了。它们各自用了一种完全合法的摆放文本的方式,而天真的提取器读错了

提取出的 PDF 文本为什么丢词间空格?

提取文本丢词间空格,因为 PDF 从不要求含有空格。生成器可以靠显示一个空格字符来分隔单词,但也完全可以靠 TJ 数组里的数字(ISO 32000-1 §9.4.3)或一次新的 Td(§9.4.2)挪笔,TeX 输出、许多 Distiller 文件和多数两端对齐版式正是这么干的。v2.766.76 之前,HPDFAssemblePageText 只看垂直移动,靠定位制造的词断就此消失。装配器现在沿前一个字形的书写方向测量,从该字形自身宽度的终点到当前字形原点的距离,距离超过当前字形盒高度(用户空间下从上伸到下伸)的 0.15 时插入一个空格。任一侧本来就是空白时不加空格,两个 CJK 字符之间也不加,因为两端对齐会把汉字拉开,而那段拉开并不意味着词边界。字形记录暴露同样的几何,所以某个文件让你困惑时,你可以自己重放这个判定

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // 字形盒从上伸到下伸的高度,用户空间
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // 横排文本:从前一个字形自身宽度的终点起算的间隙
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

为什么从字形自身宽度量起,而不是笔的位置?

HotPDF 从 GlyphEndX / GlyphEndY 量词间隙,因为字形之后的笔位置已经含有不是间隙的间距。ISO 32000-1 §9.4.4 定义水平位移为字形宽度乘字号,加字符间距 Tc,加词间距 Tw,全部再乘 Tz。BaselineEndX / BaselineEndY 存的是完整位移,而 GlyphEndX / GlyphEndY 只存字体推进量和 Tz。区别对这种生成器要紧:先用负 Tc 收紧字距、再在每个字形后用 TJ 调整把空隙还回来。从笔位置量起,还回来的部分看着像间隙,中文的「95后」就提取成了「9 5 后」。阈值绑在字形盒高度而不是 Tf 字号上,理由类似。Word 导出常写 1 Tf、把真实尺寸放进缩放过的 Tm,于是 Tfs 说是 1 而文本有 10 磅高,绑在 Tfs 上的规则会把同一页面的两种写法区别对待

Delphi 中 HotPDF 对 ExtractLoadedPageText 的词间空格规则:只有当前一字形的 GlyphEndX 到后一字形的 BaselineStartX 的距离超过上伸到下伸盒高度的 0.15 时才插入空格,因为 BaselineEndX 里的笔位置已经含 Tc、Tw 和 Tz,会把两端对齐的字距回还变成「9 5 后」这样的假间隙
决定词断的是几何、不是空格字符——字形记录暴露同样的测量值,任何让你困惑的文件都可以重放判定

这条规则有诚实的边界。字距拉得极开的标题——单 Tc 就把字母间空出超过文本高度的 0.15——提取时每个字母之间都有空格,页面看着确实如此,但多半不是你想索引的样子。同一条基线上乱序绘制的片段产生负间隙,直接相连不加空格。两种情形在正文里都少见,而在测试语料上,这次修改对参考提取器在 28 个页面上提高了词匹配、没有降低任何一处

HotPDF 什么时候在提取文本里开新行?

从 v2.766.79 起,当前一字形原点到当前字形原点的移动投影到前一书写方向的法线上、超过两个字形中较大盒高的一半时,开新行。更早的规则拿原始 Y 移动和 Tfs 的一半比,两个方向都会错。在 1 Tf 加缩放 Tm 下阈值缩到半个单位,于是 0.4 的 text rise 抬起的上标、或普通的基线抖动都会断行。这条规则还完全无视 X,所以旋转 Tm 下的文本随每个字形沿页面向下走,一行一个字形。投影到方向法线上让旋转文本表现得像横排;取两者中较大的高度,让大号示例词和它的小注解共享基线时留在同一行。上面那张税表的行数从 156 降到 97。书写模式 1 的竖排文本(§9.7.4.3)走另一条路径:那些字形按列分组,从右到左、从上到下读,每次换列断一行

Delphi 中 HotPDF 的 ExtractLoadedPageText 如何决定换行:字形原点间的移动投影到书写方向的法线上,与较大盒高的一半比较,于是 1 Tf 字体下被小 text rise 抬起的上标、以及旋转 Tm 下沿页面下行的文本,不再一行一个字形
投影让旋转文本表现得像横排,取较大的盒高让大号示例词和它的小注解留在同一行

ExtractLoadedPageText 包含哪些文本、丢掉哪些?

ExtractLoadedPageText 返回阅读器显示的文本。从 v2.766.80 起,它只从可见字形出发,丢掉盒中心落在 GetLoadedPageVisibleBox——也就是裁到 MediaBox 内的 CropBox(§14.11.2)——之外的所有字形。这去掉了设在裁切区之外的印前标记行和其他印刷标记。ExtractLoadedPageGlyphs 刻意继续返回页面内容流的每个字形,需要那些材料时你仍然找得到。这个过滤是盒测试、不是可见性测试:被裁剪路径藏住、画成白色或被图像盖住的文本照样提取

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // 页面内容流的每个字形,印前标记行也在内
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // 只有页面显示的内容,Form XObject 文本已拼接进来
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

从 v2.768.3 起,经 Form XObject 画出的文本是页面文本的一部分。页眉、图章和水印常常住在表单里,改动之前一些标准文档丢了 30% 到 35% 的字符。THotPDF.InterpretContentWithForms 把每个 Do 连同当时的 CTM 记下来,在表单的 /Matrix 乘该 CTM(§8.10.1)下解释表单,并把表单的字形拼接到 Do 的位置上,嵌套表单递归处理。没有自己的 /Resources 的表单借用绘制它的流的那份,§7.8.3 允许。表单字形带着 TokenIndex = -1,ExtractLoadedPageGlyphs 仍只返回页面流的字形,因为搜索、替换和涂黑要经 TokenIndex 把修改写回去,混进一个表单字形就会改错字节。两个简化值得知道:表单文本不裁到表单的 /BBox,递归在 12 层封顶而不是靠环检测,所以一个画自己的畸形表单会重复它的文本直到触顶

Delphi 中 HotPDF 提取 PDF 页面文本时包含哪些字形:ExtractLoadedPageText 只保留盒中心落在 GetLoadedPageVisibleBox(裁到 MediaBox 的 CropBox)之内的字形,印刷标记行就此消失,而 InterpretContentWithForms 在每个 Do 位置拼接 Form XObject 字形并置 TokenIndex 为 -1,字形级 API 仍然全量返回
对字形中心的盒测试不是可见性测试——白字、被裁剪的字和被盖住的字照样出来,表单文本从 v2.768.3 起计入

Q 操作符之后的文本为什么解成了乱码?

v2.766.73 之前 Q 之后的文本可能解错,因为提取器只在 q 上保存 CTM。文本状态参数——字体、字号、Tc、Tw、Tz、TL、渲染模式和 rise——属于图形状态(§9.3.1),所以 Q 必须把它们和栈上其他一切一起恢复(§8.4.2)。一份行业报告在 q … Q 里选了一个双字节 Identity-H 字体,随后显示不带自己 Tf 的单字节 WinAnsi 文本。提取器留住了内层字体,把目录页的前导点和「Adobe」按双字节码读,丢了页面 15% 的字符。解释器的 q/Q 栈现在保存完整文本状态。这里讲的提取规则适用于每一页,所以整个文档可以一次调用写进文件

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // 空范围 = 所有页;页间用换页符;带 UTF-8 BOM
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

该用哪个 HotPDF 文本 API?

ExtractLoadedPageText 保持内容流顺序,这是搜索和索引的正确默认;它下面的解码链在用 HotPDF 从已加载 PDF 提取文本里有讲。对创作顺序要紧的标签文档,按结构顺序的文本提取走结构树而不是从几何里猜;对锁在表格里的数据,跨页的类型化表格提取返回单元格而不是行。完整 API 参考和试用版下载在 HotPDF Delphi PDF Component 产品页