技術文章

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 的完整選項、狀態和記錄參考