技术文章

Delphi:修掉两端对齐文字造成的 PDF 表格误报

PDFium Component 3.117.0 不再把两端对齐的段落报成空白对齐表格:它要求每条列边界都是一条纵向走廊,在它所分隔的每一行上都没有文字;跳过已经被线框网格认领的词;并按垂直重叠而不是字形盒中心距离来拼装单元格文字。这三处改动都在 ExtractTables 和 ExtractDocumentTables 内部,不需要任何选项

引发这一切的那份报告一点都不光鲜。一页完全没有表格的新闻稿,从 ExtractTables 出来时带着一张 5x4 的空白对齐表格,置信度舒舒服服地高于默认的 MinConfidence 0.5,而单元格里装的是普通正文的碎片。一份入学申请表也被它的议论性段落坑了,吐出 3x4 和 5x3 两张表。两份文档都设了两端对齐。最直觉的反应是去调阈值,而这次发布最有用的教训就是调不了——因为被调的那条规则问错了问题

uses
  PDFium;

// 回归检查:列出文档里每一张空白对齐表格,好确认某个
// 你明知只有散文的页面是干净的
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  Tables := Pdf.ExtractDocumentTables(Options);
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

两端对齐的文字为什么看起来像表格?

两端对齐的段落看起来像表格,是因为对齐后的一行就是一串由排版引擎拉开的间隙分隔的词;而一旦某个被拉开的间隙达到 MinColumnGap,检测器在行内就没有办法把它与列分隔符区分开。PDFium Component 的空白策略把词框归并成视觉行,在任何与上一个词的水平距离至少为 MinColumnGap(默认 12 点)处把该行切成词组,并在至少连续两行重复出现至少 MinColumns 个左对齐锚点、且落在 AlignmentTolerance(3 点)之内时判定为一张表格。这就是表格检测总览里描述的那条规则,对一张真正对齐的表格来说它完全正确

现在把这条规则用到二十行两端对齐的 10 点散文上。每一行都被拉到同一个右边界,所以以长词结尾的行会把内部的空格撑开,而在一个含几行短行的段落里,其中一些空格会越过 12 点。连续两行只需要各自有一个被撑开的间隙,落在同一个 X 位置的 3 点以内,就足以构成一个两行两列的候选。行数一多,这就不是运气不好,而是概率趋近于必然;新闻稿上那张 5x4,不过是四个这样的间隙在五行上排到了一起的那一次而已

PDFium Component 中两端对齐的散文为何被判成表格的示意图:每一行都被拉到同一个页边距,于是单个间隙在不同行的不同 X 上越过 MinColumnGap,而两个落在 AlignmentTolerance 内的相邻间隙造出了那些现在被走廊检查拒绝的假候选
真正的表格在每一行都重复它的列锚点,而两端对齐的段落每一行撑开的是不同的空格——这就是只靠行级调参分不开两者的原因

每一个阈值都是在拿一类文档换另一类。把 MinColumnGap 提到 20 点,就会丢掉密集财务报表里紧凑的列——而默认值当初下调恰恰是为了它们。把 MinRows 提到 3,会丢掉真正的两行表格,对长段落只是把概率压低一点。把 AlignmentTolerance 收紧到 3 点以下,会弄坏从 OCR 得来的词框——它们的左边缘抖动幅度比这还大。行级信号本身就是含混的,所以修复只能来自一种行本身不携带的信号

什么才让一条列边界是真的?

真正的列边界是页面上一条纵向窄带,在它所分隔的每一行上都保持为空。表格在每对列之间天然就有这样一条,因为单元格是按共享的 X 位置排出来的。两端对齐的段落每一行撑开的词间空格都在不同的水平位置,所以没有哪条窄带能挺过一两行以上的交集。PDFium Component 现在检查的正是这一点:候选的词组被分配到锚点列之后,对每一对相邻列,它在两格都有内容的每一行上取「左格词的最右边缘到右格词的最左边缘」这个区间,把各行区间求交,如果交集窄于 MinColumnGap 乘 0.5(默认值下是 6 点),就否决整个候选

PDFium Component 中 ExtractTables 背后的无字走廊检查示意图:每一行贡献「左格右边缘到右格左边缘」这个区间;在真正的表格里交集宽于 MinColumnGap 的一半,在两端对齐文字里则塌成零
真正的列边界在它所分隔的每一行上都是空的,所以把逐行间隙求交之后,表格留下一条共享窄带,被撑开的散文则一条都不剩

有两个细节要紧。任一格为空的那些行不参与表决,所以带空单元格的表格、或者表头跨列数少于表体的表格依然能通过。另外,走廊宽度是从 MinColumnGap 推导出来的,而不是单独暴露成一个选项,因为这两者描述的是同一件物理上的东西:设计者在列与列之间留下的间隙。如果你是在裸词框而不是表格 API 上做开发,这段逻辑小到可以照抄;下面的示例就是组件内部那项检查的翻版:

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // Row * ColumnCount + Column

// 任何一对相邻列只要在用到它的那些行上缺了一条宽度至少为
// MinColumnGap / 2 的无字纵向走廊,就返回 False
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // 空单元格不参与表决
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

线框表格为什么被提取了两次?

线框表格被提取了两次,是因为空白那一趟过去会看到页面上的每一个词——包括线框那一趟已经放进网格里的词,而一张干净的线框表格按构造同时也是一张完美对齐的空白表格。原本已有一项重叠检查,会否决边界覆盖了某张既有表格一半以上的空白候选;但一个把该表格下部几行与表格下方几行对齐文字拼在一起的候选,可以落在这个比例之下,作为第二张稍大、还渗进邻居的表格活下来。ExtractTables 现在会在空白那一趟运行之前把这些词移除。当一个词的中心点落在任何由线框那一趟产出的表格边界之内时,它就被丢掉;用中心点而不是完全包含,是为了让一个只压线零点几个点的词跟着它在视觉上所属的那张表格走。空白策略此后只在剩下的自由词上工作,这也意味着一张紧贴在线框表格下方的小型无框线表格会按它自己的资格被检测出来,而不是和上面的网格粘在一起

为什么「Purpose of Request:」变成了「of Purpose Request:」?

词序被打乱,是因为 PDFium Component 构建的词框是字形包围盒的并集,而「of」没有下伸部,「Purpose」和「Request:」有。FPDFText_GetCharBox 返回的是字形墨迹在页面空间里的紧贴框,而不是按字体 ascent 和 descent 加过 padding 的框,而词框是它各个字符框的并集。所以没有下伸部的词更矮、垂直中心更高——在那份表单上高出 2 到 3 点。旧的单元格文字例程先按中心 Y 排序(对「同一行」留 1 点容差),再按左边缘排序;「of」越过了那个容差,被当成独立一行排在其余之上,于是最先被输出

与其说这是 PDFium 的怪癖,不如说是 PDF 定位文字方式的后果。ISO 32000-1 §9.2.2 和 §9.4.4 把字形定位定义为文本空间里沿基线的水平位移,而文件携带的垂直度量只有逐字体的那些:§9.8.1 里字体描述符的 Ascent、Descent 和 FontBBox 项。文件里没有任何东西说两个字形共享一行;那只能从几何推断出来,而让选取高亮看起来很准的那些紧贴字形框——用 PDFium 字符框做文本行选择那篇有描述——恰恰是中心距离比较最不该用的输入

3.117.0 的修复把问题从「中心相距多远」换成「两个框在垂直方向重叠多少」。单元格文字的拼装方式是:先把该单元格的词归并成视觉行——当一个词与所在行的动态边界在垂直方向的重叠至少为两者中较矮高度的 25% 时就并入该行——再对每一行按左边缘做插入排序,然后各行之间用换行连接。「Purpose」和「of」在整段 x-height 上重叠,远超过较矮框的 25%,所以它们落进同一行并如愿按 X 排序

PDFium Component 中 Purpose of Request 词序修复的示意图:FPDFText_GetCharBox 给出的紧贴字形框让没有下伸部的 of 中心更高,旧的 1 点中心 Y 容差把它排成了独立一行;而 25% 垂直重叠规则把它留在基线上,恢复了词序
中心 Y 会随墨迹碰巧带的上伸部和下伸部而移动,而同在一条基线上的两个框,无论各自多高,都会在共享的 x-height 上重叠

按重叠而不是中心距离来归并文本行

从这个 bug 里值得带走的规则是通用的:任何靠「比较垂直中心是否落在某个固定容差内」来判断「同一行」的 PDF 文字排版代码,遇到真实字体会失败,而且失败是静默的——不报错,词就是顺序不对。下伸部混杂是最温和的触发条件。12 点粗体标签挨着 10 点数值、上标脚注标记、从回退字体画出来的货币符号,还有每个词高度都带噪声的 OCR 词框,都会让中心移动得比任何「仍能分开 12 点行距下 10 点文字的相邻两行」的容差还多。重叠比例与字号无关:同在一条基线上的两个框,不管各自的上伸部和下伸部怎样,都在共享的 x-height 上重叠;而相邻两行的两个框,重叠为零

同一条规则在表格提取之外也容易套用。TPdf.PageWordBoxes 返回当前页上每个词及其页面空间矩形,所以把一页归并成视觉行就是一个很短的循环:

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // 每行的动态并集
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // 读取之前先按 Rect.Left 对每一行排序;PageWordBoxes 是按内容流顺序
  // 返回词的,那个顺序不保证是视觉顺序
end;

既有调用方会有什么变化,边界又在哪里

那段示例的要点是那个谓词,不是那个循环;除了一次快速 dump 之外,都该从结构化文本模型出发——它本来就带着文本块、行和阅读顺序来源,带阅读顺序的结构化 PDF 文本提取那篇有讲。既有的表格调用方不用碰任何选项就能拿到这三处修正。走廊阈值固定为 MinColumnGap 的一半;空白策略即使把 MinRows 设成 1 也保持两行的下限(线框策略现在则接受 1);只要两种策略都打开,先线框后过滤词就是无条件的。在这次发布所用的 13 份样本文档上,空白那一趟过去会连同 9 张线框表格一起返回 34 条碎片和误报;发布之后它一条都不返回,而线框表格数升到 41——不过这个增长大部分来自同一次发布教线框检测器去读画成填充矩形的边框,那是另一个故事了

诚实的边界:走廊检查至少需要一行在边界两侧都有内容才能否决掉什么,所以一个两行候选,如果那两个被撑开的间隙恰好落在彼此 6 点以内,依然能通过。这比起从前那种近乎必然已经是很窄的巧合了,但散文密集、又没有真正两行表格的文档,可以把 MinRows 设成 3 来堵上它。左对齐、参差不齐的文字从来不是问题,也不受影响。另外,PDF 仍然没有表格对象;ISO 32000-1 §14.8.4.3 定义了 Table 结构元素,但只有 Tagged PDF 才携带它,所以对其余一切而言,网格仍然是从几何推断出来的,而每个 TPdfTable 上的置信度值之所以存在,是因为推断值得有个分数

表格提取、结构化文本和词框在 Delphi、C++Builder 和 Lazarus 里都读同一个页面模型;完整 API——包括 TPdfTableExtractionOptions 和一同交付的 TableExtractionLab 演示——在 PDFium Component for Delphi 页面上有描述