技术文章

Delphi 跨页提取带类型的 PDF 表格

HotPDF 通过 ExtractLoadedTypedTables 从现有 PDF 中恢复表格。这是一个 Delphi API,会合并布局阶段生成的行片段,为每个表格建立一个规范列网格,在几何关系允许时跨页面延续表格,并将每个单元格作为带类型的值返回,同时携带页面来源、列跨度和边界。ExportLoadedTypedTables 还可以将同一结果直接写入 CSV 或 JSON。值得构建它的场景很枯燥,却极其常见。一份四十页的发票登记册在逻辑上是一个表格,但打印时每页顶部都会重复表头。对它运行朴素的阅读顺序提取,会得到四十个表格、三十九行伪造的表头,以及一个在中间单元格恰好为空时每行向左滑动一列的货币列。在调用应用程序下游清理这些问题,正是文档导入项目最容易走向失败的地方

为什么 PDF 页面给你的是片段,而不是表格

因为除非文档带标签,否则 PDF 页面根本不携带表格语义。内容流只包含文本显示操作符和定位矩阵(ISO 32000-1 第 9.4.3 节),除此之外什么也没有;你在屏幕上看到的表格线只是独立绘制的路径,任何提取器都没有义务将它与文本关联。结构元素类型 TableTRTHTD 只存在于带标签 PDF 的逻辑结构层级中(ISO 32000-1 第 14.8.4 节),而流通中的绝大多数业务文档都没有标签。下面描述的全部内容都是几何恢复,而不是解析,这一点值得在任何人基于它构建对账报告之前明说

因此 HotPDF 会先对提取出的字形运行语义布局分析,这也是从已加载 PDF 提取结构顺序文本以及结构化 HTML 和 XML 导出的基础。该阶段会把基线分组成垂直对齐的行,并且只有在连续行具有相同单元格数量时才会继续同一个行序列。对于布局引擎来说,这条规则正确且便宜;对于调用方来说,它却是错误形态:一个内部单元格为空的行,就会把一个视觉表格拆成两个源表格。带类型的表格层正是位于该阶段之上,用来把这些片段重新合并

规范列网格与 ColumnTolerance 调节项

ExtractLoadedTypedTables 会先合并同页片段,然后才做其他处理,而且依据的是列几何位置而不是行文本。当两个相邻源表格都至少有两列、前一个表格最后一行与后一个表格第一行之间的垂直间距处于容差范围内,并且列起始位置对齐时,它们就会合并。处于 ColumnTolerance 范围内的列起始位置会折叠成一个规范列,并在合并过程中取平均。默认容差为 12 个用户空间单位,适合普通业务排版;对于字距很宽或缩进很深的布局,则需要调大

缺少内部值的行会怎样,是最关键的部分。HotPDF 将每个单元格吸附到最近的规范列起始位置,然后把 ColumnSpan 设置为该列到下一个被占用列之间的距离,而不是把剩余单元格左移。五列网格中的三单元格行会让值仍然落在正确的表头下,同时准确记录空缺位置。这就是可用于对账的表格与会静默错配金额的表格之间的区别

var
  Pdf: THotPDF;
  Options: THPDFTypedTableExtractionOptions;
  Tables: THPDFTypedTables;
  Info: THPDFTypedTableExtractionInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('register.pdf', '') <= 0 then
      Exit;
    Options := THPDFTypedTableExtractionOptions.Default;
    Options.ColumnTolerance := 12;           // 用户空间单位
    Options.MinimumTableConfidence := 0.55;  // 低于此值的表格会被丢弃
    Options.DateOrder := ttdoDMY;            // 03/04/2026 表示 4 月 3 日
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount 与 Info.SourceTableCount 显示合并了多少内容
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

跨页合并到底保证什么

它有意保证保守性。只有在 MergeAcrossPages 启用、第二个表格恰好从第一个表格结束页的下一个页面索引开始、两者至少都有两列,并且至少两个规范列起始位置落在 ColumnTolerance 内对齐时,HotPDF 才会跨页面连接两个表格。连续页面这一条件是承重部分。调用方可以按任意顺序传入开放数组 PageIndices,如果没有这一检查,请求第 3、9 和 14 页就可能把三个互不相关的表格焊接成一个看起来完全合理的结果。代价是,真正的延续如果跳过一页、遇到交错附录或双面扫描中的空白背面,就会作为两个表格返回,没有任何选项会放宽这一点。是否重新连接它们只能由调用应用决定,因此 API 暴露 FirstPageIndexLastPageIndexSourceTableCount 和逐行的 PageIndex,把决定权留在应该拥有它的位置

重复表头会被标记,绝不会删除

ExtractLoadedTypedTables 从不从结果中删除重复表头行。当跨页合并发现输入表格开头的表头文本与累计表格相同(比较前会裁剪空白并折叠大小写)时,会将这些行标记为 IsHeaderIsRepeatedHeader,并仍按源顺序追加。删除是有损且不可逆的选择,而不同消费者需要不同答案:CSV 导入想要去掉重复项,审计轨迹希望保留重复项及页码,差异工具希望逐字节保持源顺序。因此库负责报告,调用方负责决定

var
  T, R, C: Integer;
  Row: THPDFTypedTableRow;
  Total: Double;
begin
  Total := 0;
  for T := 0 to High(Tables) do
    for R := 0 to High(Tables[T].Rows) do
    begin
      Row := Tables[T].Rows[R];
      if Row.IsRepeatedHeader then
        Continue;                    // 只保留第一块表头
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

带类型的值,以及必须提供的分隔符

类型推断按固定顺序运行,以唯一合理的方向解决歧义:先布尔值,再日期、百分比、货币,最后是普通数字,无法匹配的内容保持为字符串。顺序可以防止日期列中的 2026 在日期解析器看到它之前,先被数字解析器决定。货币可以识别开头的 $£¥,也可以识别空格后跟三字母 ISO 4217 代码的形式,代码会保留在 CurrencyCode 中。关键在于 HotPDF 不会猜测你的区域设置。DecimalSeparatorThousandsSeparatorDateOrder 都来自选项,因为 1.234 可能是一个数字,也可能是一千二百三十四,PDF 并不包含决定这一点的事实。每个单元格都会在带类型值旁边保留原始 Unicode Text,因此错误猜测始终可以恢复,不需要第二次提取

var
  Stream: TFileStream;
  Info: THPDFTypedTableExtractionInfo;
begin
  Stream := TFileStream.Create('tables.json', fmCreate);
  try
    if not Pdf.ExportLoadedTypedTables([0, 1, 2], ttefJSON,
      Stream, Options, Info) then
      case Info.Status of
        ttesInvalidOptions:   ReportBadConfiguration;
        ttesBudgetExceeded:   ReportOversizedDocument;
        ttesCancelled:        ReportUserCancelled;
        ttesWriteFailed:      ReportDestinationProblem;
      else
        ReportExtractionFailure;
      end;
  finally
    Stream.Free;
  end;
end;

两种导出格式回答不同问题,且有意不等价。CSV 会把合并跨度中的延续列写成空字段,这是电子表格或批量加载器所需要的形式。JSON 保留提取知道的一切:独立类型下的类型化值、columnSpan、逐单元格和逐行置信度、单元格边界,以及页面和源表格来源。两种格式都会先将整个文档暂存到有界内存缓冲区中,然后才发布到目标流;如果写入中途失败,会恢复原始字节、长度和位置,因此失败的导出不会留下半写入文件。页面数、每页字形数、表格数、行数、单元格数、字符数和输出字节数的预算都会分别核算,而且会在分配前统计行数,因为逐行执行 SetLength 很早就会退化成二次复制,远在默认的百万行上限之前就会如此

几何表格恢复在哪里放弃

明确失败模式比罗列功能更有用,因为下面每一项都是调用方需要自己制定策略的地方,而不是继续寻找更好的选项值

  • 不会恢复纵向合并。HotPDF 会为横向跨度报告 ColumnSpan,并让 RowSpan 保持为 1,因此打印表格中跨越三行的单元格会作为一个单元格加两个空缺到达
  • 表头检测由数据驱动,而不是由视觉样式驱动。表头块是第一行包含非字符串类型值之前的连续行,因此正文完全是文本的表格,无论样式如何设置,HeaderRowCount 都会报告为零
  • 低于 MinimumTableConfidence 的表格会从结果中丢弃,但不会产生错误。需要知道是否有内容被丢弃时,请比较 Info.TableCountInfo.SourceTableCount
  • 一个行序列至少需要两行和两列,布局阶段才会把它称作表格,因此只有一行的伪表格,或由长段落组成的两列布局,会被正确却不太有帮助地判定为不是表格
  • 扫描页面不包含文本操作符,因此在页面拥有 OCR 文本层之前,没有可以通过几何方式恢复的内容

如果你的 PDF 来自自己的报表系统,解决这些问题最便宜的方式是在上游:输出带标签的表格,或保留源数据,并把提取视为对非自产文档的回退方案。其他情况下,值得按这个顺序理解流水线,因为每一层都建立在下一层之上:先从已加载 PDF 做普通文本提取开始,几何关系需要保留时再上移到带类型表格 API;如果你负责生成端并且可以决定输出有多容易恢复,再查看将数据表渲染为新 PDF

ExtractLoadedTypedTablesExportLoadedTypedTables 都属于原生的 HotPDF Delphi PDF Component,面向 Delphi 和 C++Builder,不需要外部 DLL 或运行时依赖;产品页提供带类型表格 API 的完整选项、状态和记录参考