從 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 形輪廓或圓角頁籤過不了,什麼也不貢獻
活下來的矩形按包圍盒對 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 這一輪在明明有表格的頁面上回報無表格
對齊步驟收集每個上場的 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 處停止
合併的順序有分量。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 產品頁