技术文章

PDFium Delphi 里把填充矩形当作表格线

一份从 Word 或 Google Docs 导出的表格,可以在一页上带着几百段边框,却没有一条描边线。用 qpdf --qdf 解压页面,内容流读起来像一列 re f 对,每对描述一个半点高的盒子。只找描边线段的表格检测器一无所获,退回到按词间空白对齐文字,把作者画成一张干净网格的东西还成一堆两三行的碎片。边框全都在那里——它们是用填充画出来的,所以检测器必须把填充矩形读成表格线,再把几乎相接的边拉到共享网格线上

为什么 Word 表格没有描边线

文字处理器把单元格边框当成一个有宽度的盒子,用填充操作符把它画出来。ISO 32000-1 §8.5.2.1 把 re 定义为追加一条闭合矩形子路径,§8.5.3 把绘制操作符分成两半:S 用当前线宽描路径,f 填充内部。0.5 点的边框出来就是 x y w 0.5 re f,描边机构(线宽、连接、虚线样式)全程不参与。单元格底纹是同一构造,只是盒子更大。面向线条的检测器期待的那种描边形式 m、l、S,恰恰是办公导出很少用的:

% 文字处理器导出的一个单元格边框:0.5 点高的填充盒
72 700 468 0.5 re f
% 单元格底纹:与单元格等大的填充盒
72 676 117 24 re f
% 只看描边的检测器在找的那种描边网格线
72 700 m 540 700 l S

如果直接在 PDFium 原生 API 上写自己的检测器,第一稿通常是调 FPDFPath_GetDrawMode、描边标志置位就留下路径、然后继续。上面两个填充盒都过不了这个测试。单元格里的文字落到空白检测手里,而紧凑导出里列间距可能只有 6 点。PDFium Component 的默认 MinColumnGap 是 12 点,这些列根本记不上账。把 MinColumnGap 一路下调到它们能记账,两端对齐段落被拉开的词距也开始记账,而作者画的那张网格永远不会回来——定义它的信息住在填充盒里

PDFium Component 怎么把填充盒读成表格线

PDFium Component 从页面上的每个路径对象读取表格几何,一次一个子路径。由于 TPdfTableExtractionOptions.DetectFilledRulings 默认为 True,填充模式不是 none 的路径会和描边路径一起接受表格线检查。随后的过滤刻意从严:填充子路径要合格,得有四到八个点、不含曲线段、每个点都落在子路径包围盒的角上(误差 0.05 点以内)。用 re 画的矩形过关,用五个显式点加重复角点画的也过关。三角形、雪佛龙、L 形轮廓或圆角标签都不合格,什么也不贡献

PDFium Component for Delphi 如何给闭合填充子路径分类的示意图:带曲线的轮廓和点不在包围盒角上的多边形被丢弃,厚度不超过 MaxRulingThickness 的盒子沿长轴给出一条表格线,带底纹的单元格给出四条边线,极小的方块什么也不给
闭合子路径只有坐标轴对齐才能活下来,包围盒随后决定它是一根表格线、带底纹单元格的四条边,还是什么都不是

活下来的矩形按包围盒对照 MaxRulingThickness(默认 3 点)分类。宽度不超过阈值而高度超过,在水平中心给出一根贯穿盒子全高的纵向表格线;镜像情形给一根横向的。两个维度都超过阈值,盒子按带底纹单元格处理,贡献四条表格线,每边一条。两个维度都不超过,盒子什么也不贡献,所以列表里一个 2 点见方的项目符号永远不会变成一小截表格网格。填充颜色在整个过程里不起任何作用:白纸上的白盒子和黑盒子产出同样的表格线,这也是为什么一张与表格等大的背景面板会以外框的形式出现

描边路径走自己的路。任何直线段,只要两端点在一个轴上相差不超过 RulingTolerance(默认 1 点),就沿另一个轴成为一根表格线;曲线段跳过,闭合子路径的收尾段也算数。既填充又描边的路径从两条路线都贡献,重叠的碎片随后由合并步骤收拢。描边的圆角矩形仍然给直边贡献表格线,只有四角的弧线退出。提取调用本身不需要任何特殊设置:

uses
  System.SysUtils, PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I, J, WithText: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 1 起

    Options := TPdfTableExtractionOptions.Default;
    // 填充表格线相关字段,按默认值逐一写明
    Options.DetectFilledRulings := True;
    Options.MaxRulingThickness := 3.0;       // 点;必须大于 0
    Options.RulingSnapTolerance := 4.0;      // 点;0 表示关闭吸附
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      WithText := 0;
      for J := 0 to High(Tables[I].Cells) do
        if Tables[I].Cells[J].Text <> '' then
          Inc(WithText);
      Writeln(Format('%dx%d %s, confidence %.2f, %d of %d cells hold text',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence, WithText, Length(Tables[I].Cells)]));
    end;
  finally
    Pdf.Free;
  end;
end;

把 MaxRulingThickness 设为 0 并不能关掉这个功能。ExtractTables 会预先校验几何选项,对零或负的阈值抛出 EPdfError,消息为「Table extraction geometry options are invalid」。想要只描边的行为,把 DetectFilledRulings 设为 False。凡是办公套件或浏览器打印对话框产出的东西都让它开着——严格的矩形过滤正是成本保持低位的原因,装饰性填充几乎过不了它

RulingSnapTolerance 对带底纹的表格做了什么?

RulingSnapTolerance 正是让一张纯靠底纹搭起来的表格连成一整张网格的东西。有些导出根本不画边框:每个单元格都是一个填充盒,与邻居之间隔 1 到 3 点的白色栏间距。每个盒子给出自己的四条边线,但一个单元格的右边线和下一个单元格的左边线可能相距 2 点,而判定两根表格线是否属于同一网格的测试只允许 RulingTolerance,即 1 点。没有吸附,每个单元格都是描述一张 1x1 网格的四线孤岛,什么也够不到 MinColumns 或 MinRows(默认都是 2),线框通道在一张明明有表格的页面上报不出任何表格

PDFium Component for Delphi 中 RulingSnapTolerance 的示意图:相邻带底纹单元格留下 2 点栏间距,它们位于 X 230.0 和 232.0 的边线超出 1 点的 RulingTolerance,吸附把两个值聚成 231.0,两个单元格共享一条网格线
吸附在合并之前、也在线框检测器之前运行,白色栏间距的两侧变成一条线,每个单元格不再是一座四线孤岛

吸附步骤收集所有在场的 X 坐标——每根纵向表格线的位置加每根横向表格线的起点和终点——Y 坐标同样处理。每张列表排序后按「相邻两项相差超过容差」切开成簇;每个簇用均值替换,随后每个位置、起点和终点都移到最近的中心。230.0 和 232.0 的边都变成 231.0,两个单元格共享一条线,横向表格线随之拉伸,四角得以相接

在 RulingTolerance 容差内相触或重叠的共线片段会合并成单线。网格检测器到这时才登场,逐对比较表格线;以 720 个边框片段抵达的 30 行表格,走到这一步只剩几十条线

在描边网格上这一步什么也不改变,已经一致的坐标吸附到自己身上。陷阱在于链式合并没有自己的宽度上限。一串彼此相距 3 点的值——100、103、106、109——会收拢到 104.5 一个中心上,尽管两端相距 9 点。按默认的 4 点,这只会咬到比一个字符还窄的列,办公输出可以保持 4 点默认。表单刻意做了窄列(勾选框、一位一格的账号字段)时往 1 或 2 降,设 0 则完全关闭吸附。只跑线框策略、比较三种设置,就知道页面依赖的是哪个机制:

// 只跑线框策略,空白检测掩盖不了结果
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// 对仅以带底纹单元格绘制、栏间距 2 点的表格,预期结果:
//   仅描边                  -> 0(完全没有描边几何)
//   填充盒,吸附 4          -> 1(栏间距两侧共享一条线)
//   填充盒,吸附 0          -> 0(每个单元格仍是 1x1 孤岛)
Writeln('stroke only:          ', CountRuledTables(Pdf, False, 4.0));
Writeln('filled boxes, snap 4: ', CountRuledTables(Pdf, True, 4.0));
Writeln('filled boxes, snap 0: ', CountRuledTables(Pdf, True, 0.0));

第三个计数低于第二个时,这一页依赖的是吸附——在调低容差之前,这是值得先用眼睛检查的页面

form XObject 里的表格线

排版工具经常把一张表格、甚至整页正文包进 form XObject 再用 Do 画出来。ISO 32000-1 §8.10.1 规定绘制 form 时其矩阵与当前变换矩阵串联,所以 form 内部的矩形写在 form 空间里,要经过两次以上变换才抵达页面空间。IncludeFormXObjects 开着(默认即开)时,PDFium Component 走进每个 form 对象,把对象矩阵与父矩阵组合,再把组合后的矩阵传给每个子对象,于是平移过的 form 里写着的 72 700 468 0.5 re f 边框落在查看器显示的位置。嵌套 form 的遍历在八层处停住

PDFium Component for Delphi 中 form XObject 内表格线的示意图:写成 72 700 468 0.5 re f 的细矩形活在 form 空间里,只有 form 矩阵与父变换按正确顺序组合之后才落到页面顶部
矩形写在 form 空间里,矩阵按能把平移项留在正确位置的顺序相乘之后,它才抵达页面顶部

组合的顺序要紧。form 内部的点先过对象矩阵、再过父矩阵;操作数一换,平移项就在错误的空间里生效,本该在页面顶部的表格线落到原点附近或页面外。自己构建矩阵时同样会踩这个坑,矩阵前乘还是后乘一文用一个旋转枢轴把这件事走了一遍

为什么表格线预算默认是 16384?

默认的 MaxRulingSegments 16384 反映的是逐单元格边框导出会产出多少碎片。一张描边的 30 行 6 列表格需要 38 条线段:31 横 7 纵。同一张表格导出成填充盒,每个单元格可以带四段边框,合并前 720 段;每个单元格再铺底纹又加四条边线,合计 1440。一页上放两张这样的表格,就已经逼近 3000

超出预算是错误,而且它就按错误报出来。ExtractTables 抛出 EPdfError,消息为「Table ruling-segment budget exceeded」,不返回半张网格,该页的空白检测通道也不跑。对不可信输入,紧的预算是合理的——构造出来的页面可能塞着几百万个小盒子——但那样调用方就得把「超出这份预算」和「这页没有表格」区分开:

function TryExtractTables(Pdf: TPdf; Budget: Integer;
  out Tables: TPdfTables): Boolean;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.MaxRulingSegments := Budget;     // 默认 16384
  try
    Tables := Pdf.ExtractTables(Options);
    Result := True;
  except
    on E: EPdfError do
    begin
      // LogWarning 是你自己的日志例程,不是库的一部分
      LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
      Tables := nil;
      Result := False;                     // 超预算或选项非法
    end;
  end;
end;

// False 表示「未能判定」,与空数组是两回事
if not TryExtractTables(Pdf, 4096, Tables) then
  QueueForReview(Pdf.FileName, Pdf.PageNumber);  // 同样是你自己的例程

同一个异常类型也覆盖词预算和非法选项,所以日志里的消息才是在复核队列里分辨它们的依据

线框通道在哪里交棒

线框检测先跑,空白检测后跑,两条规则让它们互不踩脚。中心点落进线框通道已报网格的词,会在空白检测开始前移除,表格不会被报两次。空白列边界还必须是贯穿其分隔的每一行的无文字纵向走廊,两端对齐的段落因此得不了表格的分;空白表格误报一文讲的正是这条规则。把填充矩形读进来,把办公导出的表格从空白通道挪进了线框通道

表格线来自路径对象、文字来自文本页,所以没有文本层的页面仍会产出网格骨架、每个单元格都是空的。线框网格的置信度是 0.88 加 0.12 乘有文字单元格的占比,空骨架恰好报 0.88,第一段代码的输出里 0 个单元格有文字就是这个信号——该先跑 OCR 了,用 OCR 加可搜索文本层讲的就是这件事。带曲线或非矩形轮廓的填充图形什么也不贡献,单元格是填充圆角矩形的表格落到空白检测。既无边框也无底纹的表格属于表格检测与提取一文讲的空白策略;连它都不够用时,结构化文本与阅读顺序给出的词框和块,就是为单一文档家族写读取器的原料

组件自带的 TableExtractionLab demo 有一个 DetectFilledRulings 复选框,加载一份导出文件,看网格出现又消失,一行代码都不用写。选项、记录类型和提取 API 的其余部分列在PDFium Component for Delphi 产品页