一份从 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 形轮廓或圆角标签都不合格,什么也不贡献
活下来的矩形按包围盒对照 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),线框通道在一张明明有表格的页面上报不出任何表格
吸附步骤收集所有在场的 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 的遍历在八层处停住
组合的顺序要紧。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 产品页