技術文章

Delphi 中跨 PDF 頁面的內容感知表格接續

PDFium Component 從 3.117.0 版起,會連結跨頁中斷的表格:只要兩個片段都碰到頁緣,或是第一個片段下方、第二個片段上方都沒有內文,就把兩個接起來,而頁眉與頁腳一律忽略。ExtractDocumentTables 把這個內容感知判斷當成舊頁邊界判斷之外的替代路徑,拒絕第一列是單一跨欄標題儲存格的下一頁片段,並把溢到下一頁的單一列保留在它的接續鏈裡

表格偵測與抽取一文把接續寫成四道嚴格關卡,並把「碰到頁緣」列為其中一道。那個描述對它當時涵蓋的那個版本是準確的,但對大多數人真正餵給元件的表格來說也是錯的。本文是修正:哪些文件是邊界判斷處理不了的、什麼取代了它,以及這次修正順帶拖進來的兩個邊緣情況

為什麼頁邊界判斷在 Word 匯出的檔案上會失效?

頁邊界判斷會失效,是因為文書處理器排到頁面下邊界就停,不是排到紙張邊緣。在預設的 ContinuationMargin 為 36 點的情況下,原本的規則要求前一個片段的下緣落在頁面底部 36 點之內,後一個片段的上緣落在頁面頂端 36 點之內。一份從 Word 以預設一英吋邊界匯出的文件,會把最後一列擺在頁面底部至少 72 點以上,有頁腳時更遠,所以那個條件從來不成立。這種文件裡每一張長表格都回來成各自獨立的片段、ContinuationGroup 是零,呼叫端又得回到手工縫合。這個判斷對它當初針對的對象仍然說得通:由排版引擎產生、把一頁填滿到固定內容框、下一頁從頂端貼齊開始的報表。它不是一條壞規則,是一條不完整的規則,這就是為什麼 3.117.0 保留它、另外加一條路徑,而不是把它換掉

內容感知判斷改檢查什麼?

內容感知判斷檢查的是兩個片段之間那塊空間被什麼占著——除了表格以外還有沒有別的東西——用的是每一頁的文字方框,而不是頁面幾何。ExtractDocumentTables 走訪文件時,會逐頁記錄:頂端落在頁腳帶之上的文字中,最低的那個下緣;以及底部落在頁眉帶之下的文字中,最高的那個上緣。兩個帶的深度都是 ContinuationMargin 點,所以同一個選項現在兼作兩用:既是頁緣的餘裕,也是頁眉與頁腳區帶的高度。一對片段通過的條件是:前一個的下緣至少低到它那一頁最低的內文,後一個的上緣至少高到下一頁最高的內文,兩者各自在 AlignmentTolerance 之內。講白一點:表格是第 N 頁的最後一樣東西,也是第 N+1 頁的第一樣東西,而落在邊界帶裡的頁碼或文件標題不算數。這個排除不是隨意的。ISO 32000-1 §14.8.2.2 把頁眉與頁腳歸類為分頁假象,也就是因為分頁才存在、而不是無視分頁而存在的內容;讓標記過的讀取器得以略過它們的同一個道理,也正是讓表格得以穿過它們繼續下去的道理。標記內容一文談標記過的檔案怎麼明確宣告這些假象;在這裡,分類是從位置推論出來的,因為大部分匯出的表格根本沒有標記

為什麼 PDFium Component 的表格接續需要兩道判斷:Word 用一英吋邊界時,頁邊界判斷要求片段邊緣落在 36 點的視窗內,而排版永遠到不了那裡;內容感知判斷則比對文字方框,在表格是第 N 頁最後一段內文、也是第 N+1 頁第一段內文時把它們連結起來,並忽略頁眉與頁腳區帶
兩道判斷任一通過就開門,之後才跑其餘檢查:頁碼相鄰、後一個片段沒有跨欄標題列,以及欄界線在兩倍 AlignmentTolerance 之內相符

兩道判斷以 OR 結合。表格一路排到紙張邊緣的排版引擎報表過第一道;表格停在邊界的 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 頁的片段

PDFium Component 裡的標題列關卡:真正的續篇以資料儲存格開頭,並加入同一個 ContinuationGroup;而第零列是單一合併儲存格、其 RowIndex 為 0、ColumnIndex 為 0、ColumnSpan 等於 ColumnCount 的後續片段,會被拒絕承認為續篇,改回報為新表格
檢查只碰後一個片段,所以自己第一列就是標題的表格不受影響;而這道關卡在兩道邊緣判斷之一已經把那一對連起來之後才跑

接著的欄位比對 TablesHaveMatchingColumns 比「欄數相同」嚴格。它從儲存格矩形重建每個片段的界線位置,內插被合併儲存格遮住的界線,並在任何界線漂移超過容差時拒絕這一對。所以兩張同樣四欄、比例卻不同的表格,即使其他一切都對得上,仍然保持分開

溢到下一頁的單一列會怎樣?

一個把某一列帶到下一頁的有框線網格,現在會被偵測到並連結起來——前提是它最後進到某條接續鏈裡;單獨出現的話還是會被丟掉。預設的 MinRows 為 2,是為了不讓兩條孤線被當成表格回報,但被擠過分頁點的最後一列是真的一列,卻被 2 這道硬地板默默丟掉了,而表格其餘部分看起來很完整,其實並不。文件層級的掃描用三個步驟處理它。當 DetectContinuations 與 DetectRuledTables 都設定時,逐頁的那一趟會把有框線偵測器的列數地板暫時降到 1,這就是為什麼 ExtractTables 現在接受有框線網格的 MinRows 為 1,而空白偵測仍然保有內部地板 2。接續關係會標在完整結果上。接著,每一張比呼叫端 MinRows 短、又不屬於任何鏈的表格都會被移除。單列片段能活下來只是因為它被連結了,而一張落在一頁普通頁面中間的單列網格,照舊被過濾掉

PDFium Component 如何保住被分頁點切開的有框線列:當 DetectContinuations 與 DetectRuledTables 都設定時,逐頁的有框線那一趟以列數地板 1 執行,接續關係標在完整結果上,最後只移除比 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 更深的頁腳,會讓它下面幾行留在內文區裡,於是前一個片段看起來後面有文字,連結就被擋住;把選項提高到真正的帶深,像第一個範例那樣。提得太高,頁面底部附近一段短短的收尾段落就滑進帶裡被忽略,於是表格被連到它後面的任何東西。標題規則有個鏡像的失敗模式:一個會在每個接續片段的第一列寫上合併的「續下頁」橫幅的產生器,那些片段會被當成新表格拒絕,而今天唯一的補救辦法是在什麼都不放寬的前提下自己按 ContinuationGroup 縫合,因為這條規則沒有開關

空白偵測出來的表格完全沒有那份單列寬減。空白策略至少要有兩列對齊才看得見表格,所以一張溢出一列的無框線表格,仍然會少報那一列。當您碰上這個,結構化文字區塊與閱讀順序背後的文字方框會給您原始位置把它救回來。在推動這次改動的樣本集——十三份文書處理器與瀏覽器匯出檔——裡,五份真正有多頁表格的文件全都連成單一鏈,而先前黏在一起的成績單表單則保持分開;這是這次發行被拿來衡量的門檻,不是對每一種排版的承諾

接續標記、標題規則與單列那一趟,全都住在 Delphi、C++Builder 與 Lazarus 版本共用的文件層級路徑裡;完整的表格抽取 API 說明在 PDFium Component for Delphi 產品頁