技術文章

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

Word 匯出的表格跨了三頁,卻從來不會距離紙張底部 72 點以內。一英吋邊界是 72 點,文書處理器到邊界就停止排列表格列。只在片段相距頁緣 36 點以內才連結的連結器,看到的是三張欄位完全相同、卻互不相干的表格。有效的問法不同:除了每頁重複的頁首或頁尾,第一個片段的結尾與下一個片段的開頭之間還有沒有別的東西?

這個問題自帶兩個陷阱。表單可能在第 1 頁底結束一張表格,又在第 2 頁頂用相同的欄寬開出另一張表格;而格線表格也可能把單獨一列推過分頁點,短到想先看到兩列才承認是表格的偵測器無法認領

ExtractDocumentTables 裡的兩道邊緣測試

PDFium Component 在 TPdf.ExtractDocumentTables——每頁版 ExtractTables 的文件層級對應版本——裡標記接續,每頁版在表格偵測與擷取一文介紹過。設了 DetectContinuations(TPdfTableExtractionOptions.Default 預設就開)之後,這個方法擷取每一頁,按頁碼、再按每頁內由上而下的順序排序表格,然後檢查清單裡每一對相鄰項。只比較相鄰項。所以接續候選永遠是第 N 頁最低的表格配第 N+1 頁最高的表格,而第 N 頁放在長表格底下的小表格,會頂替長表格進入這個配對。短備註表格坐在長表格下面時,請在自己的程式碼裡按頁碼與 ColumnCount 自行連結這些片段

第一道測試純幾何。前一個片段的下緣必須落在頁底 ContinuationMargin 點之內,後一個片段的上緣落在下一頁頁頂同樣距離之內。預設 36 點,半英吋。填滿固定內容框的排版引擎報告輕鬆過關。一英吋邊界的 Word 匯出兩邊都至少差 36 點,而把邊界放大到過關也是錯的,因為同一個數字還設定著第二道測試的頁首與頁尾區帶

第二道測試,也就是內容感知的那道,才是處理文書處理器輸出的關鍵。兩者以 OR 結合:任一道通過就開閘

兩個片段之間什麼算文字?

頁面上每個詞都算內文,除非它完全落在每頁頁首區帶或頁尾區帶裡,兩條區帶的深度都是 ContinuationMargin 點。每頁 ExtractDocumentTables 記錄兩個極值:頂端在頁尾區帶之上的詞裡最低的下緣,以及底端在頁首區帶之下的詞裡最高的上緣——表格自身的儲存格文字也算在內。表格是第 N 頁最後一段內文時,它的下緣位於最低詞的下緣或更低,測試在 AlignmentTolerance(預設 3 點)之內通過。後一個片段得在第 N+1 頁頂滿足鏡像條件

忽略區帶在規範裡有依據。ISO 32000-1 §14.8.2.2 把頁首、頁尾與頁碼視為分頁產物——只因為文件被切成頁面才存在的內容。有標籤的檔案可以用 /Artifact 宣告它們,標記內容一文解釋過這點,但最需要表格縫合的匯出檔很少帶標籤,所以用位置代替標籤。用預設區帶時,字形頂端高於底邊 30 點的頁碼會被忽略;頂到 40 點的免責聲明是內文,它會擋下連結

沒有文字層的頁面上,存放的極值退回頁面邊界,內容感知測試直接通過,由其餘關卡決定。畫成影像或向量輪廓的頁尾基於同樣原因不可見,也從不擋連結

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

通過邊緣測試只是開閘。頁碼必須相鄰,後一個片段不得以標題列開頭,欄界線必須在兩倍 AlignmentTolerance(預設下 6 點)之內對齊。全部成立時,前一個片段成為帶新群組編號的 ptcStart,或從 ptcEnd 升為 ptcMiddle(若它已收束一條鏈),後一個片段則成為同一群的 ptcEnd。跨三頁的表格讀作 start、middle、end。群組編號從 1 起;群組 0 加 ptcNone 表示表格獨立。ToJson 以 "continuationGroup" 與 "continuation"(none、start、middle 或 end)寫出同樣的資料,收 JSON 的服務可以自行縫合

下面的清單假設 Word 匯出、頁尾是約 50 點深的兩行文字,所以邊界調到 54,讓兩行都落在區帶內

uses
  System.SysUtils, 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;  // 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 x %d',
          [Tables[I].PageNumber, Tables[I].RowCount,
           Tables[I].ColumnCount]));
      end;
  finally
    Pdf.Free;
  end;
end.

全寬首列如何把兩張表格隔開

後一個片段的首列若是橫跨所有欄的單一儲存格,會被回報為新表格,就算邊緣測試通過、欄位也相符。表單正是內容感知測試會失手的地方:一張表格在第 1 頁底收尾,下一節在第 2 頁頂以相同欄寬開場,所有幾何檢查全過。差別在第二張表格的開場列——像 RECIPIENT INFORMATION 這樣、以全寬合併儲存格排版的節標題。真正的接續不會這樣開場,因為表格的標題留在表格開始的那一頁

這條規則只讀後一個片段的儲存格。該片段需要至少兩欄,以及一個 RowIndex = 0、ColumnIndex = 0 且 ColumnSpan = ColumnCount 的儲存格。前一個片段從不被檢查,所以標題自身坐在第 N 頁頂的表格,照常連到第 N+1 頁的接續

PDFium Component 的標題列閘門:真正的接續以資料儲存格開場並加入同一個 ContinuationGroup,而第 0 列只有一個 RowIndex 0、ColumnIndex 0、ColumnSpan 等於 ColumnCount 合併儲存格的後續片段,會被拒絕為接續並回報為新表格
檢查只碰後一個片段,所以標題列坐在自己首頁的表格不受影響;這道閘門只在兩道邊緣測試之一通過後才運行

接下來的欄位比較比欄數相等嚴格。每個片段的邊界位置從其儲存格矩形重建;被合併儲存格遮住的邊界,按兩側最近的已知邊界均勻內插補出。然後逐對比較邊界,差異超過 6 點就拒絕連結。兩張比例不同的四欄表格會被隔開;真的接續若產生器在兩頁之間把某欄移了 8 點也一樣被拒,調高 AlignmentTolerance 來接納它的同時,也放寬了偵測時用來聚簇的標準

跨過分頁的一條格線列

格線表格把單獨一列推到下一頁時,接進鏈裡就保留這一列,沒接進去就丟掉。預設 MinRows 為 2,擋下兩條游離框線被誤報成表格。被推過分頁的列不同:它是真資料,丟掉它,縫好的表格就短一列,而且再也沒有片段能解釋這個缺口

文件掃描用三步處理。DetectContinuations 與 DetectRuledTables 都開時,格線偵測器在每一頁以降到 1 的列數下限運行。接著在完整結果上標記接續。最後,列數低於呼叫端 MinRows、又沒進任何鏈的表格會被移除。普通頁面中央的單列格線表格走過這道過濾器就消失,這正是 MinRows 的用途

PDFium Component 如何保留溢過分頁的格線列:DetectContinuations 與 DetectRuledTables 都設定時,每頁格線掃描以列數下限 1 運行,接續在完整結果上標記,只有短於 MinRows、又不在任何鏈裡的片段會被移除
溢出的列因為被鏈連著而活下來;普通頁面上獨立的單列格線表格照常被過濾,空白偵測的表格則維持兩列下限、沒有這種寬免

MinRows 維持 2。自己設成 1 等於關掉最後那道過濾器,每頁的每個單列格線表格都會回來

這個例外只適用格線表格。空白策略至少要兩列對齊才認得表格,所以不管 MinRows 說什麼,它內部維持 2 的下限;ExtractTables 接受 MinRows = 1,但只有格線偵測真的照辦。無框線表格溢出一列時,頁面的文字方框,或結構化文字區塊與閱讀順序一文描述的區塊,給你自行救回那一列的位置

把鏈縫回一張表格

鏈回來時是連續的。連結只發生在排序後陣列的相鄰項之間,所以 ptcStart 片段之後直接跟著它的 ptcMiddle 與 ptcEnd 片段;在 ptcNone 或 ptcStart 時重置、在 ptcNone 或 ptcEnd 時輸出的迴圈,完全不必按群組編號查任何東西

重複的表頭列是麻煩所在:Word 會在每一頁重複它,其他產生器不會。TPdfTableCell 的 IsHeader 旗標定不了這件事,因為它是按位置指定的——RowIndex < HeaderRowCount——而且每個片段分開算。接續的第 0 列不管重複表頭還是帶資料都會被標成表頭,上一節那條單列溢出也被標成表頭。下面的常式把每條接續首列的文字與鏈首列比較,只在完全相等時丟掉

它也從 Cells 建列,而不是把 ToCsv 輸出按行切開。儲存格內文字換列時,回傳的文字在視覺行之間帶 CR LF,ToCsv 把這個斷行留在引號欄位裡,直接刪掉 CSV 文字的第一行會把兩行的表頭儲存格攔腰切斷

// Reader-side helpers (not part of the library): rebuild each chain
// 成一份 Tab 分隔檔,丟掉後續頁面重複的表頭
function RowText(const T: TPdfTable; Row: Integer): string;
var
  Parts: array of string;
  I: Integer;
begin
  SetLength(Parts, T.ColumnCount);
  for I := 0 to High(T.Cells) do
    if (T.Cells[I].RowIndex = Row) and (T.Cells[I].ColumnIndex >= 0) and
       (T.Cells[I].ColumnIndex < T.ColumnCount) then
      Parts[T.Cells[I].ColumnIndex] :=
        StringReplace(string(T.Cells[I].Text), #13#10, ' ', [rfReplaceAll]);
  Result := '';
  for I := 0 to High(Parts) do
  begin
    if I > 0 then
      Result := Result + #9;
    Result := Result + Parts[I];
  end;
end;

procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R, FirstRow: Integer;
  Header: string;
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      FirstRow := 0;
      if Tables[I].Continuation in [ptcNone, ptcStart] then
      begin
        Lines.Clear;
        Header := RowText(Tables[I], 0);
      end
      else if (Trim(Header) <> '') and (RowText(Tables[I], 0) = Header) then
        FirstRow := 1;                     // 表頭在這一頁重複了
      for R := FirstRow to Tables[I].RowCount - 1 do
        Lines.Add(RowText(Tables[I], R));
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Lines.SaveToFile(Format('%s\table%d-page%d.tsv',
          [Folder, I, Tables[I].PageNumber]));
    end;
  finally
    Lines.Free;
  end;
end;

完全比對是正確選擇。文書處理器重複的表頭逐字元相符,模糊比對遲早吞掉一列長得像表頭的資料列。輸出裡多出一個表頭好發現;少了一列資料不好發現

量測頁尾區帶,而不是用猜的

ContinuationMargin 一個數字幹三份工作:第一道測試的頁緣寬容,以及第二道測試的頁尾區帶深度與頁首區帶深度。頁尾比區帶深時,它的上面一行留在內文區,前一個片段看起來像後面還跟著文字,鏈就在那頁斷掉。區帶設得太深則開始吞掉真內容:緊貼下邊界的簡短結尾行落進區帶,其上的表格就可能連到下一頁開場的任何東西

文件自己就能供給這個數字。TPdf.PageWordBoxes 回傳當前頁每個詞的頁面空間 Rect,其 Top 大於 Bottom,所以位於內文邊界之下的詞裡最高的 Top 就是頁尾深度。下面的探測取排版下邊界略低一點的值——一英吋頁取 70——並保留 36 點做為沒有頁尾文件的下限

// Reader-side helper (not part of the library): the highest word top
// BodyMargin 之下最高的文字上緣,也就是頁尾區的深度
function MeasureFooterDepth(Pdf: TPdf; BodyMargin: Double): Double;
var
  P, I: Integer;
  Words: TPdfWordBoxes;
begin
  Result := 0;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    Words := Pdf.PageWordBoxes;
    for I := 0 to High(Words) do
      if (Words[I].Rect.Bottom < BodyMargin) and
         (Words[I].Rect.Top > Result) then
        Result := Words[I].Rect.Top;
  end;
end;

// 需 uses System.Math 才有 Max
Options := TPdfTableExtractionOptions.Default;
Options.ContinuationMargin := Max(36.0, MeasureFooterDepth(Pdf, 70) + 2);
Tables := Pdf.ExtractDocumentTables(Options);

兩條區帶深度相同,所以深的每頁頁首也對頁頂量一次,取較大值。寬容維持在兩三點內;每多一點,內容感知測試就少看見一點內文

有一種版面天性上就擊敗標題規則:產生器把合併的「續表」橫幅寫成每個接續片段的首列。這種片段個個被回報為新表格,所以請在自己的程式碼裡,按連續頁碼、相等的 ColumnCount 與第 0 列的橫幅文字自行連結

接續標記、標題規則與單列例外全部運行在 ExtractDocumentTables 之內,在 Delphi、C++Builder 與 Lazarus 版本上行為一致。完整的表格擷取選項列在 PDFium Component for Delphi 產品頁