PDFium Component 3.117.0 会在满足下面任一条件时把跨分页边界断开的表格链接起来:两个片段都碰到页面边缘,或者第一个片段下方、第二个片段上方都没有正文,页眉页脚一概不算。ExtractDocumentTables 把这套内容感知判断作为旧页边距判断之外的又一条路径,拒绝首行是全宽标题单元格的下一页片段,并把溢到后一页的单行保留在它的延续链里
表格检测与提取那篇把跨页延续描述成四道严格的关卡,其中一道就是「碰到页面边缘」。那段描述对它当时覆盖的那个版本是准确的,但对人们实际喂给这个组件的多数表格又是错的。本文是更正:页边距判断处理不了哪些文档、拿什么替代了它,以及这次修复顺带拖出来的两个边缘情况
为什么页边距判断在 Word 导出的文件上失灵?
页边距判断失灵,是因为文字处理器在下方页边距处就停止排行了,而不是排到纸张边缘。默认 ContinuationMargin 是 36 点,原来那条规则要求前一个片段的下边缘落在距页面底部 36 点以内,后一个片段的上边缘落在距页面顶部 36 点以内。而一份用 Word 默认一英寸页边距导出的文档,最后一行至少高于页面底部 72 点,有页脚时还要更高,所以这个条件永远不成立。这类文档里的每一张长表格都被当成独立片段返回,ContinuationGroup 为 0,调用方又得手工拼接。这项判断对它当初的设计对象仍然说得通:由排版引擎生成的报表,会把页面填满到固定的内容框,并从下一页顶部齐平开始。它不是一条坏规则,而是一条不完整的规则——所以 3.117.0 保留了它,并另外加了一条路径,而不是把它替换掉
内容感知判断改查什么?
内容感知判断检查的是两个片段之间的空间是否被表格之外的东西占据,用的是每页的词框而不是页面几何。ExtractDocumentTables 在遍历文档时会按页记录两个量:所有顶部位于页脚带之上的词里最低的那个下边缘,以及所有底部位于页眉带之下的词里最高的那个上边缘。两个带都是 ContinuationMargin 点深,于是同一个选项现在一职两用:既是页面边缘的容差,也是页眉页脚区域的高度。当较早片段的下边缘不高于本页最低的正文,且较晚片段的上边缘不低于下一页最高的正文,各自在 AlignmentTolerance 之内时,这一对片段就通过。说白了:这张表格是第 N 页的最后一样东西,也是第 N+1 页的第一样东西,而页边距带里的页码或文档标题不算数。这个排除不是随意的。ISO 32000-1 §14.8.2.2 把页眉页脚归类为分页产物,也就是因为分页才存在的内容,而不是尽管分页仍然存在的内容;让带标签的阅读器跳过它们的那套思路,正是让表格能越过它们继续下去的思路。marked content 那篇讲了带标签的文件如何显式声明这些产物;这里的归类是从位置推断出来的,因为多数导出的表格根本没有任何标签
两套判断是「或」的关系。排版引擎生成的报表如果表格一直排到纸张边缘,就通过第一套;Word 导出如果表格停在页边距处,就通过第二套;两者都符合的文档通过两次。只有在其中一套成功之后,剩下的关卡才会运行,而且顺序固定:页码必须相邻,较晚片段的首行不能是标题行,列边界必须在两倍 AlignmentTolerance(默认值下是 6 点)内吻合。枚举类型是 TPdfTableContinuation,取值有 ptcNone、ptcStart、ptcMiddle 和 ptcEnd。一个已经被标成 ptcEnd 的片段如果又往后链接到另一页,会被提升为 ptcMiddle,所以一张跨三页的表格按页码顺序读出来是 start、middle、end。组号从 1 开始,0 表示未链接;ToJson 会把这些信息输出成 continuation 和 continuationGroup 成员——如果拼接是下游服务做的,优先用这种形式
uses
PDFium;
var
Pdf: TPdf;
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
I: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'itinerary-from-word.pdf';
Pdf.LoadDocument;
Options := TPdfTableExtractionOptions.Default;
Options.DetectContinuations := True; // 默认值;此处为清晰起见写出
Options.ContinuationMargin := 54; // 两行页脚,约 50 点深
Tables := Pdf.ExtractDocumentTables(Options);
for I := 0 to High(Tables) do
case Tables[I].Continuation of
ptcStart:
Writeln(Format('group %d starts on page %d (%d rows)',
[Tables[I].ContinuationGroup, Tables[I].PageNumber,
Tables[I].RowCount]));
ptcMiddle, ptcEnd:
Writeln(Format('group %d continues on page %d (%d rows)',
[Tables[I].ContinuationGroup, Tables[I].PageNumber,
Tables[I].RowCount]));
else
Writeln(Format('standalone table on page %d (%d rows)',
[Tables[I].PageNumber, Tables[I].RowCount]));
end;
finally
Pdf.Free;
end;
end;
标题行如何阻止两张表格粘在一起?
首行是一个横跨所有列的单元格的下一页片段,会被当成新表格,绝不会被当作上一张表格的剩余部分。这条规则存在,是因为内容感知判断单靠自己能链接得太积极。暴露它的案例是一份成绩单式的表单:一张表格在第 1 页底部附近结束,另一张列宽完全相同的表格在第 2 页顶部附近开始,两者之间只有页脚,而且列对得整整齐齐。在页边距判断下这两张永远不会相遇,因为谁都没碰到边缘;在内容判断下它们立刻链接起来,一份分节的表单变成了一个莫名其妙的整块网格。区分它们的东西在单元格结构里看得见。第二张表格以一个节标题开头,比如「RECIPIENT INFORMATION」,排版成横跨全宽的一个合并单元格;而真正的延续从不这样,因为标题属于上一页已经开始的那张表格。TableStartsWithCaptionRow 编码的正是这一点:片段至少有两列,并且存在一个 RowIndex = 0、ColumnIndex = 0、ColumnSpan = ColumnCount 的单元格。这项检查只对较晚的片段运行,所以自己带标题行的表格在第一页上不受影响;标题在第 N 页,被检查的只有第 N+1 页的片段
接下来的列比较 TablesHaveMatchingColumns 比「列数相同」严格得多。它会从单元格矩形重建每个片段的边界位置、插值出被合并单元格遮住的边界,并在任何一条边界漂移超过容差时拒绝这一对。所以两张比例不同的四列表格,即便其他一切都对得上也依然分开
溢到下一页的单行会怎么处理?
一张把某一行带到下一页的线框网格,现在会被检测到并链接起来——前提是它最终落进了一条延续链;单独出现时它照样被丢弃。默认的 MinRows 为 2,是为了避免把偶然的两条线报成表格;但被挤过分页的那最后一行是真实的一行,被这个硬性下限悄悄丢掉了,而表格剩下的部分看上去还是完整的。文档级扫描分三步处理它。当 DetectContinuations 和 DetectRuledTables 同时打开时,逐页那一趟会用临时降到 1 的行下限运行线框检测器——这也是 ExtractTables 现在对线框网格接受 MinRows 为 1、而空白对齐检测内部仍保留下限 2 的原因。接着在整个结果上标记延续。然后移除每一张短于调用方 MinRows、又不属于任何链的表格。单行片段能活下来,只是因为它被链接上了;而一张平平无奇的页面中间孤零零的单行网格,还是和以前一样被过滤掉
// 把每条链重建成一个 CSV,并丢掉延续片段上
// 重复出现的表头行
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
I, R: Integer;
Lines: TStringList;
Csv: TStringList;
begin
Csv := TStringList.Create;
Lines := TStringList.Create;
try
for I := 0 to High(Tables) do
begin
if Tables[I].Continuation in [ptcNone, ptcStart] then
Csv.Clear;
Lines.Text := string(Tables[I].ToCsv);
if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
(Lines.Count > 1) and (Tables[I].RowCount > 1) then
Lines.Delete(0); // 文字处理器重复出来的表头
for R := 0 to Lines.Count - 1 do
Csv.Add(Lines[R]);
if Tables[I].Continuation in [ptcNone, ptcEnd] then
Csv.SaveToFile(Format('%s\page%d-group%d.csv',
[Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
end;
finally
Lines.Free;
Csv.Free;
end;
end;
这段例程里有两个细节是刻意的。单行外溢永远不会被剥掉,因为 RowCount 上那道守卫会把它留住;而一个在每页重复表头行的文字处理器,会让片段的第一行又是表头,所以对 middle 和 end 片段丢掉第零行在那种情况下是对的,对一个不重复表头的生成器则是错的。在把这个例程放出去跑一整个文件夹之前,先拿一份文档试一下
这些规则的边界在哪
内容感知判断的好坏只取决于它读到的那层文本。在一页完全没有文字的扫描页上,记录下来的正文极值会退回页面边界,「中间什么都没有」这个条件就被空洞地满足了,只剩标题行和列这两道关卡;这种页面上的线框网格仍然会被当成一副空骨架找出来,所以链可能链接得没错,但关于周围文本的一切其实都没有被验证过。如果这很重要,先加一层文本。以图像而不是文本呈现的页脚对带状逻辑是隐形的,也正是同一种原因让它无害
这两个带是一个数字。页脚比 ContinuationMargin 更深,它下面几行就留在了正文区里,让较早的片段看起来后面还有文字,从而挡住链接;那就把这个选项调到真实的带深,像第一个例子那样。调得太大,页面底部附近一段简短的收尾段落就滑进带里被忽略,于是表格被链接到它后面的任何东西上。标题规则有一个镜像式的失效:如果某个生成器在每个延续片段的第一行都写一条合并的「continued」横幅,那些片段就会被当成新表格拒绝——而今天唯一的补救办法是在什么都不放松的前提下自己按 ContinuationGroup 拼接,因为这条规则没有开关
空白对齐检测出来的表格享受不到任何单行豁免。空白策略至少要两行对齐才看得见表格,所以一张没有框线、溢出一行的表格仍然会少报那一行。碰上这种情况时,结构化文本块与阅读顺序背后的词框能给你把那一行找回来的原始位置。在推动这项工作那批样本上——十三份文字处理器和浏览器导出——五份真正有多页表格的文档全部链接成单条链,而以前会被粘在一起的成绩单式表单保持分离;这是那个版本被衡量所对照的标尺,不是对每一种版式的承诺
延续标记、标题规则和单行那一趟都住在 Delphi、C++Builder 和 Lazarus 构建共用的文档级路径里;完整的表格提取 API 在 PDFium Component for Delphi 页面上有描述