技术文章

Delphi 中用 PDFium 字符框实现 PDF 文本行选取

一个 PDF 文本页暴露出来的只有字符和它们的边界框,从来没有行。PDFium Component 构建一条视觉行的方式,是把垂直中心落在种子字符高度一半以内的字符框聚成一类,从被点击的字符向外扫描,直到超出容差为止。查看器里每一条选取路径都调用同一个辅助函数,所以鼠标、键盘和代码给出的结果是一致的

把你带来找这篇文章的症状很具体,也很讨厌。一个用户在双栏报告里三击一个段落,选中的却是半个页面。或者他们三击一个表格单元格,选中范围却把整行、连同页脚的页码都一并吞了进去。查看器没坏;它是在回答一个文件本身根本回答不了的问题。PDF 里没有"行"这种东西可以选,任何假装有的实现都是在猜。本文讲的是如何让这个猜测变得刻意、变得前后一致。如果你真正需要的是从文档里把文本提取出来,请看用 PDFium 从 PDF 文档中提取文本;如果你在排版文本、需要宽度信息,请看文本测量与自动换行。这里讲的问题更窄:判断一条视觉行从哪里开始、到哪里结束,然后精确地选中那一段

为什么一个 PDF 文本页没有行对象

因为 PDF 内容流描述的是绘制过程,不是结构。ISO 32000-1 §9.4 把文本对象定义为一对 BT / ET,中间包含定位和显示操作符。§9.4.2 的定位操作符(TdTDTmT*)在页面上移动一个文本矩阵,§9.4.3 的显示操作符(TjTJ'")则在这个矩阵当前指向的位置绘制字形。这套模型里没有任何东西表明"这一串字形是一行"。行是绘制完成之后,人眼看出来的东西

生成器还会以你无法控制的方式让情况变得更糟。一个两端对齐的段落,可能被输出成每行一个 TJ 数组,也可能被输出成每个词一个 Tj、前面各带一个显式的 Tm,还可能被输出成单一一次显示操作、靠字距调整来承载间距。一个两栏布局,可能先输出左栏从上到下的内容再输出右栏,也可能交错输出,取决于生成程序是按怎样的顺序遍历自己内部的对象列表的。PDFium 交给你的字符序列,跟随的是内容流;而内容流,跟随的是生成它的那个应用程序当时想怎么做。所以你实际拿到的只有两个函数:FPDFText_CountChars,报告这一页有多少个字符;FPDFText_GetCharBox,返回某一个字符在页面空间里的边界框。这就是全部的原始词汇。在此之上的一切——单词、行、段落、栏——都是你自己在几何信息上做的推断

为什么检测 CR 和 LF 是错误的判据

因为你想用来判断的那些字符本身就不可靠地存在,即便存在,也不可靠地属于你。PDFium 会往文本页里注入合成字符,让提取出来的文本可读:在两段视觉上分离的内容之间插一个空格,在下一段从新基线开始的地方插一个 CR 或 LF。FPDFText_IsGenerated 存在的意义正是让你能把这些和文件里原生的字符区分开,PDFium Component 把它暴露成 CharacterGenerated 属性

如果按这些字符去切分,你就继承了 PDFium 在合成它们时做出的每一个判断。一个换行段落内部的硬换行,和一次软换行,在合成之后看起来一模一样。一个生成器逐格输出的表格行,最后一格和下一行第一格之间可能根本没有插入换行,因为它们的基线恰好足够接近。与此同时,一个标题后面跟着不同字号的正文,可能会被插入两次换行,而人眼只看到一次。这些生成出来的字符只是为了让整页提取的结果更好读而做的一种渲染便利,它们不是一个行模型,而且它们恰恰在选取最要紧的那些文档上表现得最糟

按垂直中心聚类字符框

可靠的信号是几何信息。把用户点击的那个字符当作种子,算出它的边界框的垂直中心,然后向两个方向扩展,只要相邻的字符框的垂直中心仍落在容差范围内就继续。PDFium Component 用种子框高度的一半作为这个容差,并设一个 0.5 页面单位的下限,这样退化边界框——一个句号、一个细空格、一个高度接近零的字形——就不会把容差压缩到几乎为零,从而在第一个字符之后就把行截断

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

这段循环里有三个细节值得称道。容差是从种子字符推导出来的,而不是一个常量,所以一个 24pt 的标题会得到一条宽的带状范围,一段 7pt 的脚注文字会得到一条窄的,两者互不侵占对方的字符。比较用的是垂直中心,而不是基线或框顶,这样一个上标、一段字号不同的行内文本,或者一句混排字体的句子,都能和它的邻居保持在同一行里。而一次失败的 FPDFText_GetCharBox 调用会终止扫描,而不是被跳过,因为一个取不到几何信息的字符,无论哪个方向都给不了你任何证据,如果继续跳过它往后走,就可能靠着更靠后一个字符的份上,跨过一处真正的行边界

为什么每一条选取路径都必须共享同一个辅助函数

因为三条各自实现"行"这个概念的代码路径迟早会分道扬镳,而且是悄悄地分道扬镳。在 PDFium Component 里,三击展开、Shift+HomeShift+End,以及公开的 SelectLineAt 方法,全都通过同一个 LineRangeAt 调用来解析边界。三击操作从选取锚点取种子;Shift 组合键从选取光标取种子,只移动那一端;SelectLineAt 从调用方提供的字符索引取种子,把结果交给 SelectTextRange,也就是鼠标路径用的同一个范围校验器。如果分别复制一份逻辑,出的问题不会是崩溃,而是缓慢的漂移。有人为了修一份行距很紧的报告,调了三击操作的容差,结果在同一个段落上,Shift+End 停下的位置比三击操作提前了一个字符。用户用鼠标选中一行,用键盘去扩展它,却眼睁睁看着选区缩小。因为 SelectLineAt 接入的是普通的选取管线,程序化选取也就和"鼠标输入是否启用"这件事保持独立,同时还能免费得到范围校验、重绘和 OnSelectionChange 通知

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

注意 CharacterIndexAtPos 上的那两个容差参数。命中测试有自己的一份宽容度,以页面单位表达,这和行的容差是两个独立的问题。一次落在两行之间行距里的点击,会解析到那个范围内最近的字符,不管那是哪一个;行扫描随后就从那个字符开始跑。给命中测试喂一个过于宽松的容差,正是选中用户根本没有指向的那一行的常见方式之一

两套索引空间:字符索引与文本索引

拿到一段范围之后,请克制住把它当成字符串偏移量直接使用的冲动。FPDFText_GetText 返回的页面文本是一个 UTF-16 缓冲区,但它的索引和 FPDFText_GetCharBoxFPDFText_CountChars 用的字符索引不是同一套索引空间。前面提到的那些生成字符,占据着没有可用几何信息的字符槽位,却也存在于文本缓冲区里,这两套编号会在页面里逐渐错位。连接两者的桥梁是 FPDFText_GetTextIndexFromCharIndexFPDFText_GetCharIndexFromTextIndex,PDFium Component 把它们包装成了 CharacterIndexToTextIndexTextIndexToCharacterIndex

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

咬得最狠的是反过来的那个方向。一次基于提取出来的字符串实现的搜索给你的是文本索引,如果把它们直接传给某个边界框或选取 API,会悄无声息地定位到错误的字符,而且这个误差会随着在页面上越往下走越大。请先用 TextIndexToCharacterIndex 转换,再让任何几何相关的操作去碰这个数字。代理对会在此之上再叠加一层独立的偏移量问题,详情见表情符号、中日韩字符与代理对一文

这个启发式算法会在哪里失灵

要对自己诚实地承认它的局限,因为这些局限是真实存在的,也是能被碰到的。旋转文字是最清楚的情形:一个字符框在页面空间里是一个轴对齐的矩形,所以对于旋转了 90 度的文字,同一条视觉行里各字符框的垂直中心会散布在页面各处,扫描几乎立刻就会停下。你得到的是一段过短的选取,而不是一段错误的选取,这是相对更好的失败方式,但终归还是一种失败。竖排书写模式出于同样的原因表现出同样的行为。双栏布局在两栏之间有垂直偏移时能正常工作,在没有偏移时就会失效。如果两栏共用同一套基线网格,右栏的字符就会落在左栏那一行的容差范围之内,扫描就会一路直穿过中间的栏间距,因为在纯几何意义上,那里根本没有任何东西能让它停下来。要检测出这一点,需要在垂直聚类之上再加一个水平间隙检测,而这个间隙阈值该定多少,本身就是另一个关于"你愿意在哪些文档上出错"的判断。混合字号是种子相对容差处理得很好的情形:11pt 正文里嵌一段 8pt 的行内代码,它的中心依然落在带状范围之内,而下一条基线上的 24pt 标题也不会把正文行拉进自己那一类

这里描述的行选取语义,随面向 Delphi 和 C++Builder 的 PDFium Component 一起提供,连同示例中用到的命中测试、选取范围和文本索引 API;产品页带有文本页与选取模型的完整参考