一页两端对齐的正文,可能从空白表格检测器回来时变成一张好几列的网格,每个单元格里装着半句被截断的话,置信度还远高于你能想到的任何阈值。那一页上根本没有表格。检测器找到的词间隙宽得可以冒充列分隔符,还有几条连续行的间隙恰好落在几乎同一个 X 位置上,仅凭对齐,这确实就是一张表格的样子。答案是:别再逐行判断一条列边界,而是问一列与一列之间是否有一条贯穿页面的空白纸带
两端对齐的文字为什么能通过空白表格测试?
两端对齐的文字能通过,是因为对齐后的一行就是一串由排版引擎拉开的间隙分隔的词,而一旦某个被拉开的间隙越过列阈值,这一行上没有任何东西能把它与列间距区分开。PDFium Component 的空白策略与大多数空白检测器一样:TPdf.ExtractTables 把词框归并成视觉行,在从一词到下一词的水平距离至少为 MinColumnGap(TPdfTableExtractionOptions.Default 里是 12 点)处切开每一行,再把所得词组的左边缘在 AlignmentTolerance(默认 3 点)内聚成列锚点。锚点只有在至少 MinRows 行上重复才成立,候选的每一行都必须命中至少 MinColumns 个不同锚点。对一张真正对齐的表格,这条规则是对的,也正是表格检测总览描述的规则
散文一侧的算术比看起来更不利。设想一份 180 点宽的新闻稿栏里一行 10 点的文字,因为下一个词放不下而在右边距前 30 点处结束,词又长,整行只有三个词间空格。对齐引擎把这 30 点摊到三个空格上,每个从约 2.5 点长到 12.5 点,越过了默认的 MinColumnGap。第二行只需要一个被撑开的间隙、其后那个词的起点落在第一行的 3 点以内,检测器就有一个两行两列的候选了
拉伸是怎么编码的无所谓。ISO 32000-1 §9.3.3 只对单字节字符码 32 应用词间距(Tw),用多字节字体的生产者通常改在 TJ 数组(§9.4.3)里用数值调整来定位词;两种方式产出的词框一模一样。每段最后一行按参差右缘排、带普通空格,这正是假阳性往往覆盖段落主体、却在段落末行前一行停住的原因
快速检查自己语料的办法,是把检测器跑在你明知只有散文的文档上——信函、新闻稿之类——并打印它报告的每张空白模式表格。预期输出是空
uses
SysUtils, PDFium;
// 列出已加载文档里每一张空白对齐表格,好快速检查
// 那些你明知只有散文的页面
procedure ReportWhitespaceTables(Pdf: TPdf; MinRows: Integer = 2);
var
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
I: Integer;
begin
Options := TPdfTableExtractionOptions.Default; // MinColumnGap 12、AlignmentTolerance 3
Options.MinRows := MinRows;
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 提到 20 点会丢掉密集财务报表里紧凑的列——标签列与数值列之间的间隙往往只比默认值宽一点。MinRows 提到 3 会丢掉真正的两行表格,对长段落只是降低一点赔率,因为一页散文有的是三行排齐的机会。AlignmentTolerance 收紧到 3 点以下会开始拆散左边缘轻微漂移的真列——带 OCR 文本层的扫描页正是这样
MinConfidence 看起来是最自然的过滤器,却是四个里最弱的。PDFium Component 里空白表格的分数是 0.58 加上非空单元格占比乘 0.30、封顶 0.88,所以分数衡量的是网格有多满。段落碎片填满每个单元格,这意味着散文假阳性得分接近区间顶端,而带空单元格的真表格得分反而低
两行两端对齐的散文与两行两列表格,可以产出相同词组、相同锚点。能把它们分开的证据坐在行与行之间
真正的列边界是一条纵向走廊
真正的列边界是页面上一条纵向窄带,在它所分隔的每一行上都保持无字。表格在每对列之间天然有这样一条,因为单元格是按共享的 X 位置排出来的。两端对齐的段落每一行撑开空格的水平位置都不同,所以没有哪条窄带能挺过一两行以上。PDFium Component 在候选的词组被分配到锚点列之后检验的正是这一点:对每一对相邻列,两格都有内容的每一行贡献「左格词的最右边缘到右格词的最左边缘」这个区间;区间跨行求交,交集窄于 MinColumnGap 的一半—默认值下 6 点时整个候选被否决
真实世界里别扭的表格有两个细节要紧。任一格为空的行不参与表决,所以带空单元格的表格、或表头跨列少于表体的表格照样通过。走廊宽度从 MinColumnGap 推导而非单独设选项,因为两个值描述的是同一件物理事物:设计者留在列与列之间的空隙。取一半,是给左列长条目逼近右列的那一行留余地
这个检查小到可以在你自己的排版代码里复现;下面的单元格列表是你自己的结构,按行主序每格一个词索引数组
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;
把线框表格挡在空白趟之外
线框表格会被找到两次,如果空白趟看到了页面上的每个词——一张干净的线框表格按构造同时也是一张完美对齐的空白表格。单靠一条「重叠超过一半就否决」的规则,一个把线框表格下部几行与表格下方几行对齐文字拼在一起的候选,可以落在这个比例之下,作为第二张更大、渗进邻居的表格出来
DetectRuledTables 和 DetectWhitespaceTables 都开着(默认如此)时,ExtractTables 因此先跑线框趟,在空白趟开始之前移除每个中心点落在线框表格边界内的词。用中心点而不是完全包含,是为了让只压线零点几点的词跟着它视觉上所属的表格走。空白策略此后只见自由词,紧贴线框网格下方的小型无框表格按它自己的行来判
为什么「Purpose of Request:」会出来成「of Purpose Request:」?
单元格里的词顺序错乱,发生在用垂直中心判断「同一行」的代码里,因为由字形框构建的词框即使共享基线也不共享中心。FPDFText_GetCharBox 返回每个字形墨迹在页面空间里的紧贴框,不是按字体上伸部与下伸部加过垫的框,而词框是它各字符框的并集。在「Purpose of Request:」这样的表单标签里,「Purpose」和「Request:」带下伸部而「of」不带,所以「of」的框更矮、中心高出几个点。一个按中心 Y 排序(1 点同行容差)、再按左边缘排序的例程,会把「of」单独排到另两个之上、先输出
PDF 没有捷径可抄。ISO 32000-1 §9.4.4 按沿基线的位移放置字形,文件里仅有的垂直度量是逐字体的:字体描述符(§9.8.1)里的 Ascent、Descent 和 FontBBox。没有任何东西说两个字形共享一行。让选取高亮看起来很准的那些紧贴框——用 PDFium 字符框做文本行选择有讲——恰恰是中心距离比较最不该用的输入
PDFium Component 改用重叠来拼单元格文字。一个词与所在行的动态边界的垂直重叠至少为两者较矮高度的 25%、或两个中心相距不到一点时,它加入这条视觉行;每行随后按左边缘排序,行与行之间用 CR LF 对连接。「Purpose」和「of」在整个 x-height 上重叠,远超较矮框的四分之一,所以落在同一行、按序读出
在你自己的代码里按重叠归并文本行
同一个陷阱等着任何拿中心与固定容差比较来判「同一行」的 PDF 排版代码。下伸部是最温和的触发条件。挨着较小数值的粗体标签、上标脚注标记、带噪声的 OCR 框,都会让中心移动得超过任何「仍能分开 12 点行距下 10 点文字相邻两行」的容差。重叠比例不在乎字号:同一条基线上的两个框在共享的 x-height 上重叠,相邻两行的两个框重叠为零
TPdf.PageWordBoxes 以 Rect 返回当前页每个词及其页面空间矩形,把一页归并成视觉行只需要一个谓词加一个短循环
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; // 需要一个活动页面(Pdf.PageNumber)
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;
值得抄走的是那个谓词。超出快速转储的需求,请从结构化文本模型出发——它已经带着块、行和阅读顺序来源,见带阅读顺序的结构化 PDF 文本提取
表格调用方仍能控制什么
表格调用方不碰任何选项就得到走廊检查、线框优先的词过滤和基于重叠的单元格文字。走廊阈值固定为 MinColumnGap 的一半,所以你为列间距调的同一个值管两边。即使 MinRows 设成 1,空白策略也保持两行的下限;只有线框策略接受单行网格,ExtractDocumentTables 用它处理页面顶部续排的表格片段。同一份选项记录传进 TPdfReflowOptions.TableOptions,DetectTables 打开时重排输出继承同样的决策
走廊检查在边界两侧都有内容的行上做决定,所以两行候选只在两行上受审。对以散文为主、很少有真两行表格的档案—合同、信函、文章—把 MinRows 设成 3,接受丢掉的两行表格。财务和表单密集的文档保持默认 2,因为短的真表格在那里很常见。参差右缘的文字保持普通单空格,几乎不会产出接近 12 点的间隙
PDF 没有通用的表格对象。ISO 32000-1 §14.8.4.3.4 定义了 Table 结构元素,但只有 Tagged PDF 携带它,所以对其余一切而言,网格是从几何推断出来的,而空白表格的 Confidence 永远不超过 0.88
检测选项、TPdfTable.ToCsv 与 ToJson 序列化,以及面向 Delphi、C++Builder 和 Lazarus 的 TableExtractionLab 演示,都在 PDFium Component for Delphi 产品页上有覆盖