技術文章

PDFium Delphi 把填色矩形當成表格框線

從 Word 或 Google Docs 匯出的表格,可以在一頁上帶著幾百個邊框片段,卻沒有一條描邊線。用 qpdf --qdf 解開頁面,內容串流讀起來就像一欄 re f 對,各自描述一個半點高的盒子。只找描邊線段的表格偵測器什麼也找不到,退回去按詞間空白對齊文字,在作者明明畫了一個乾淨網格的地方,端回來一堆兩三列的碎片。邊框全都在那裡。它們是用填色畫的,所以偵測器必須把填色矩形讀成框線,再把幾乎相觸的邊拉上共享的網格線

為什麼 Word 表格沒有描邊線

文書處理器把儲存格邊框當成一個有寬度的盒子,用填色運算子把它畫出來。ISO 32000-1 §8.5.2.1 把 re 定義為附加一條封閉矩形子路徑,§8.5.3 把繪製運算子一分為二:S 以目前線寬描路徑的邊,f 填滿內部。0.5 點的邊框出來就是 x y w 0.5 re f,描邊機器(線寬、接合、虛線樣式)全程不動。儲存格底紋是同一個構造,只是盒子更大。線段導向偵測器期待的那種描邊形式——m、l、S——恰恰是辦公室軟體匯出少用的:

% 文書處理器匯出的一個儲存格邊框:0.5 pt 高的填色盒
72 700 468 0.5 re f
% 儲存格底紋:與儲存格同大的填色盒
72 676 117 24 re f
% 只找描邊的偵測器想看到的那條描邊格線
72 700 m 540 700 l S

如果你在原始 PDFium API 上自己寫偵測器,初稿通常會呼叫 FPDFPath_GetDrawMode,描邊旗標有設就留下路徑,然後繼續往下。上面兩個填色盒都過不了那個測試。儲存格裡的文字於是落到空白偵測手上,而在緊湊的匯出裡,欄與欄可能只差 6 點。PDFium Component 的 MinColumnGap 預設 12 點,那些欄因此入不了帳。把 MinColumnGap 降到它們入帳為止,對齊段落被拉開的詞距也會跟著入帳,而作者畫的那個網格永遠回不來——定義它的資訊住在填色盒裡

PDFium Component 怎麼把填色盒讀成框線

PDFium Component 從頁面上每個路徑物件讀表格幾何,一次一條子路徑;由於 TPdfTableExtractionOptions.DetectFilledRulings 預設 True,填入模式不是 none 的路徑會與描邊路徑一起被檢視框線。接下來的過濾器刻意嚴格。填色子路徑合格的條件:點數在四到八之間、不含曲線段、每個點都落在子路徑包圍盒的角落上(容差 0.05 點)。用 re 畫的矩形過關,用五個顯式點(一角重複)畫的也過關。三角形、箭頭形、L 形輪廓或圓角頁籤過不了,什麼也不貢獻

PDFium Component(Delphi)如何分類封閉填色子路徑的示意圖:帶曲線的輪廓與有點偏離包圍盒角落的多邊形被丟棄,厚度不超過 MaxRulingThickness 的盒子沿長軸產出一條框線,底紋儲存格產出四條邊框線,極小方塊什麼也不產出
封閉子路徑只有軸對齊時才存活,包圍盒接著決定它是一條框線、底紋儲存格的四條邊,還是什麼都不是

活下來的矩形按包圍盒對 MaxRulingThickness(預設 3 點)分類。寬度在門檻以內、高度超過,得到一條位於水平中心、貫穿盒子全高的垂直框線;鏡像情形得到一條水平框線。兩個維度都超過門檻,盒子按底紋儲存格處理,貢獻四條框線,一邊一條。兩個維度都在門檻以內,盒子什麼也不貢獻,所以清單裡 2 點的方形項目符號永遠不會變成一截表格格線。填色顏色在整個過程中不起作用。白紙上的白盒產出與黑盒相同的框線,這就是與表格同大的背景面板可能冒充外框的原因

描邊路徑走自己的路。端點在某一軸上相差不超過 RulingTolerance(預設 1 點)的每條直線段,沿另一軸成為框線;曲線段跳過,封閉子路徑的收尾段也算。同時填色又描邊的路徑兩條路都貢獻,重疊的片段之後由合併步驟收攏。描邊的圓角矩形照樣為直邊給出框線;只有角落的弧段出局。抽取呼叫本身不需要特別設定:

uses
  System.SysUtils, PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I, J, WithText: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 一基

    Options := TPdfTableExtractionOptions.Default;
    // 填色框線相關欄位,把預設值明寫出來
    Options.DetectFilledRulings := True;
    Options.MaxRulingThickness := 3.0;       // 點;必須大於 0
    Options.RulingSnapTolerance := 4.0;      // 點;0 關閉對齊
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      WithText := 0;
      for J := 0 to High(Tables[I].Cells) do
        if Tables[I].Cells[J].Text <> '' then
          Inc(WithText);
      Writeln(Format('%dx%d %s, confidence %.2f, %d of %d cells hold text',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence, WithText, Length(Tables[I].Cells)]));
    end;
  finally
    Pdf.Free;
  end;
end;

把 MaxRulingThickness 設成 0 並不會關掉這個功能。ExtractTables 會預先驗證幾何選項,門檻為零或負值時引發 EPdfError,訊息是「表格抽取幾何選項無效」。要只看描邊的行為,把 DetectFilledRulings 設為 False。凡是辦公室套件或瀏覽器列印對話框產出的東西,都讓它開著;嚴格的矩形過濾器是成本低的關鍵,裝飾性填色幾乎過不了它

RulingSnapTolerance 為底紋儲存格表格做了什麼?

RulingSnapTolerance 是讓純底紋建出的表格接成一張網格的關鍵。有些匯出完全不畫邊框:每個儲存格是一個填色盒,與鄰居隔著 1 到 3 點的白色溝槽。每個盒子貢獻四條邊框線,但一個儲存格的右邊與下一個的左邊可能相距 2 點,而判定兩條框線是否屬於同一網格的測試只允許 RulingTolerance,即 1 點。不對齊的話,每個儲存格都是描述 1x1 網格的四線孤島,什麼也湊不齊 MinColumns 或 MinRows(預設皆為 2),ruled 這一輪在明明有表格的頁面上回報無表格

PDFium Component for Delphi 中 RulingSnapTolerance 示意圖:相鄰的底紋儲存格留下 2 pt 溝槽,其邊框線位於 X 230.0 與 232.0,超出 1 pt 的 RulingTolerance,對齊把兩個值聚成 231.0,兩個儲存格共用一條網格線
對齊跑在合併與 ruled 偵測器之前,白溝槽的兩側因此合成一條線,每個儲存格不再是四條框線組成的孤島

對齊步驟收集每個上場的 X 座標——每條垂直框線的位置,加上每條水平框線的起點與終點——Y 座標同理。每份清單排序後,凡相鄰兩值相差超過容差處就切開成簇;每簇以平均值取代,然後每個位置、起點與終點都移到最近的中心。230.0 與 232.0 的邊都變成 231.0,兩個儲存格共用一條線,水平框線跟著伸展,讓角落相接

共線且在 RulingTolerance 內接觸或重疊的片段被合併成單線。網格偵測器到這時才登場,逐對比較框線;以 720 個邊框片段抵達的 30 列表格,走到那個比較時只剩幾十條線

在描邊網格上這一步什麼也不改變,本來就一致的座標自己對齊自己。但要注意:鏈式聚類沒有自己的寬度上限。一串彼此差 3 點的值——100、103、106、109——會塌縮成 104.5 一個中心,儘管最外側兩值差了 9 點。用 4 點預設值時,這只會在比一個字元還窄的欄上咬人,辦公室輸出可以放心保留 4 點預設。表單刻意做窄欄時(勾選框、一格一位的帳號欄)降到 1 或 2,設 0 則關閉對齊。只跑 ruled 策略、比對三種設定,就能看出一頁依賴哪個機制:

// 只跑 ruled 策略,讓空白偵測蓋不住結果
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// 對一份只用 2 pt 溝槽的填色儲存格畫出的表格,預期結果:
//   stroke only            -> 0  (完全沒有線段幾何)
//   filled boxes, snap 4   -> 1  (溝槽兩側共用一條線)
//   filled boxes, snap 0   -> 0  (每個儲存格仍是 1x1 孤島)
Writeln('stroke only:          ', CountRuledTables(Pdf, False, 4.0));
Writeln('filled boxes, snap 4: ', CountRuledTables(Pdf, True, 4.0));
Writeln('filled boxes, snap 0: ', CountRuledTables(Pdf, True, 0.0));

第三個計數低於第二個時,這一頁依賴對齊,這就是降容差之前該用眼睛檢查的頁面

Form XObject 裡的框線

排版工具常把表格、甚至整個頁面主體包進 Form XObject 再用 Do 畫出。ISO 32000-1 §8.10.1 說,繪製 Form 時其矩陣會與目前變換矩陣串接,所以 Form 內的矩形寫在 form space 裡,要經過兩次以上變換才抵達頁面空間。開著 IncludeFormXObjects(預設開)時,PDFium Component 走進每個 form 物件,把物件矩陣與父層矩陣合併,再把合併後的矩陣交給每個子節點,所以在平移過的 form 裡畫成 72 700 468 0.5 re f 的邊框,會落在檢視器顯示的位置。走訪在八層巢狀 form 處停止

PDFium Component for Delphi 中 Form XObject 內框線示意圖:寫成 72 700 468 0.5 re f 的細矩形住在 form space,要等 form 矩陣與父層變換按正確順序合併後,才落在頁面頂部
矩形以 form space 座標撰寫,只有矩陣按讓平移項待在原處的順序相乘後,才抵達頁面頂部

合併的順序有分量。Form 內的點先過物件矩陣、再過父層矩陣;運算元對調,平移項就套進錯的空間,屬於頁面頂部的框線於是落在原點附近或頁面之外。自己建矩陣時同樣的陷阱隨時出現,矩陣前置與後置一文用旋轉樞鈕把這件事走了一遍

為什麼框線預算預設 16384?

MaxRulingSegments 預設 16384,反映的是逐儲存格邊框匯出會產出多少片段。30 列 6 欄的描邊表格需要 38 條線段:31 橫 7 豎。同一張表以填色盒匯出,每個儲存格可帶四個邊框片段,合併前 720 個;再給每格上底紋,又加每格四條邊框線,共 1440。一頁放兩張這種表,需求已近 3000

超出預算是錯誤,而且它就以錯誤的姿態現身。ExtractTables 引發 EPdfError,訊息是「表格框線段預算已超限」,不回傳部分網格,該頁的空白這一輪也不跑。緊的預算對不可信輸入是合理的——精心構造的頁面可以塞幾百萬個小盒子——但呼叫端就得把「超出此預算的複雜度」與「這頁沒有表格」分開:

function TryExtractTables(Pdf: TPdf; Budget: Integer;
  out Tables: TPdfTables): Boolean;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.MaxRulingSegments := Budget;     // 預設 16384
  try
    Tables := Pdf.ExtractTables(Options);
    Result := True;
  except
    on E: EPdfError do
    begin
      // LogWarning 是你自己的記錄常式,不是程式庫的一部分
      LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
      Tables := nil;
      Result := False;                     // 超出預算或選項無效
    end;
  end;
end;

// False 代表「無法判定」,與空陣列不同
if not TryExtractTables(Pdf, 4096, Tables) then
  QueueForReview(Pdf.FileName, Pdf.PageNumber);  // 同樣是你自己的常式

同一個例外型別也涵蓋文字預算與無效選項,所以在審閱佇列裡,能分辨它們的是記下來的訊息

Ruled 路徑交棒的地方

Ruled 偵測先跑,空白偵測後跑,兩條規則防止它們互相踩腳。中心落在 ruled 這輪已回報網格內的文字,在空白偵測開始前先移除,表格因此不會被回報兩次。空白欄邊界還必須是一條貫穿其所分隔每一列的無文字垂直走廊,對齊段落才不會被判成表格;空白表格誤報一文講的就是這條規則。讀懂填色矩形,把辦公室匯出的表格從空白路徑搬進 ruled 路徑

框線來自路徑物件、文字來自文字頁,所以沒有文字層的頁面照樣產出每格皆空的網格骨架。ruled 網格的信賴度是 0.88 加 0.12 乘以有文字儲存格的比例,空骨架恰好回報 0.88,而第一段清單輸出裡的儲存格計數會顯示 0 格有文字。這個組合就是先跑 OCR 的訊號,用 OCR 加可搜尋文字層一文有講。帶曲線或非矩形輪廓的填色圖形什麼也不貢獻,儲存格是填色圓角矩形的表格因此落到空白偵測。既無邊框也無底紋的表格屬於表格偵測與抽取一文描述的空白策略;連它都不夠用時,結構化文字與閱讀順序給出的文字框與區塊,就是為單一文件家族撰寫讀取器的原料

元件附帶的 TableExtractionLab 範例有一個 DetectFilledRulings 核取方塊,載入一份匯出檔就能看著網格出現又消失,一行程式碼都不用寫。選項、記錄型別與其餘抽取 API,列在 PDFium Component for Delphi 產品頁