一頁左右對齊的內文,可能被空白表格偵測器回報成好幾欄的網格,每個儲存格裡放著半截句子,信心分數遠高於你會想到去設的任何門檻。那頁根本沒有表格。偵測器找到的,是寬到可以冒充欄分隔的詞間空隙,加上幾行空隙幾乎落在同一個 X 位置的連續文字——單看對齊,這確實就是表格的長相。解法是停止逐列判斷欄界,改問一個問題:欄與欄之間,是否有一條空白紙帶沿著頁面垂直貫穿
為什麼左右對齊文字會通過空白表格測試?
左右對齊文字會通過,是因為對齊的一行就是一排被排版引擎拉寫過間隙的詞,一旦某個被拉寬的間隙越過欄寬門檻,單憑這一行就再也不能把它跟欄溝區分開。PDFium Component 的空白策略跟多數空白偵測器一樣運作。TPdf.ExtractTables 把詞框組成視覺列,凡相鄰兩詞的水平距離達到 MinColumnGap(TPdfTableExtractionOptions.Default 裡是 12 點)就在該處切分每列,再把切出的詞組左緣聚類成 AlignmentTolerance(預設 3 點)內的欄錨點。錨點至少要在 MinRows 列上重複出現才成立,候選的每一列也得命中至少 MinColumns 個不同錨點。對真正的對齊表格,這條規則是對的,也正是 表格偵測總覽描述的那條規則
散文這邊的算術比表面上看起來更寬鬆不到哪裡去。設想 180 點寬的通訊刊物欄位裡有一行 10 點字,因為下一個詞擺不下而比右邊界短了 30 點,而這行詞很長、只有三個詞間空格。對齊引擎把 30 點攤到三個空格上,每個從約 2.5 點長到 12.5 點,越過預設 MinColumnGap。第二行只要有一個被拉寬的間隙、且其後的詞起點落在第一行的 3 點之內,偵測器就湊出一個兩列兩欄的候選
拉寫是怎麼編碼的無所謂。ISO 32000-1 §9.3.3 的字間距(Tw)只作用於單位元組字元碼 32,所以用多位元組字體的產生器通常改用 TJ 陣列(§9.4.3)裡的數值調整來定位詞;兩種做法得到的詞框一模一樣。每段的最後一行照末尾不齊排、帶普通空格,這正是為什麼誤判往往蓋住段落主體、卻在段落結束前一行停下
快速檢查自己語料的方法:對你確定只有散文的文件(信件、新聞稿之類)跑一次偵測器,把它回報的每個空白模式表格印出來。這些文件上的預期輸出是零
uses
SysUtils, PDFium;
// 列出載入文件裡每個空白對齊表格,讓你
// 快速檢查確定只有散文的頁面
procedure ReportWhitespaceTables(Pdf: TPdf; MinRows: Integer = 2);
var
Options: TPdfTableExtractionOptions;
Tables: TPdfTables;
I: Integer;
begin
Options := TPdfTableExtractionOptions.Default; // MinColumnGap 12、AlignmentTolerance 3
Options.MinRows := MinRows;
Tables := Pdf.ExtractDocumentTables(Options); // 會還原使用中頁面
for I := 0 to High(Tables) do
if Tables[I].DetectionMode = ptdmWhitespace then
Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
'first cell "%s"',
[Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;
每個門檻的代價
每個列層級門檻都是拿一類文件換另一類,這正是針對這個問題不該去動它們的原因。MinColumnGap 提到 20 點,會丟掉緊湊財務報表的窄欄——標籤欄與數值欄之間的溝常常只比預設寬一點點。MinRows 提到 3,會丟掉真正的兩列表格,卻只是小幅降低長段落湊中的機率——一頁散文有的是三行對齊的機會。AlignmentTolerance 收到 3 點以下,開始拆散左緣稍微飄移的真欄——掃描頁加上 OCR 文字層時就是這樣
MinConfidence 看起來是最自然的過濾器,其實是四個裡最弱的。PDFium Component 的空白表格得分是 0.58 加 0.30 乘以非空儲存格占比、上限 0.88,所以分數量的是網格有多滿。段落碎片會填滿每個儲存格,也就是說散文誤判的分數貼著區間頂端,而有空白儲存格的真表格分數反而更低
兩行對齊散文與兩列表格的兩列,可以在相同錨點上產出完全相同的詞組。能把兩者分開的證據,在列與列之間
真正的欄界是一條垂直走廊
真正的欄界,是頁面上一條在它分隔的每一列都沒有文字的垂直窄帶。表格按構造在每對欄之間都有一條,因為它的儲存格本來就是對著共用的 X 位置排的。左右對齊段落每行拉寬空隙的水平位置都不同,所以沒有任何窄帶能存活超過一兩行。PDFium Component 在候選的詞組被分配到錨點欄之後,測的正是這個。對每對相鄰欄,凡兩個儲存格都有內容的列,貢獻「左欄詞最右緣到右欄詞最左緣」的區間;區間跨列取交集;交集若窄於 MinColumnGap 的一半—預設下是 6 點,整個候選就會被拒收
在彆扭的真表格上有兩個細節。某一側儲存格為空的列不投票,所以帶空白儲存格的表格、或欄數比主體少的表頭列,照樣通過。走廊寬度是從 MinColumnGap 推導的,不是獨立選項,因為兩者描述的是同一個物理量:設計者留在欄與欄之間的空間。取一半,是給左欄長條目逼近右欄的那一列留餘地
這個檢查小到可以在自己的排版程式碼裡重現;下面的儲存格清單是你自己的結構——按列主序、每個儲存格一個詞索引陣列
uses
Math, PDFium;
type
TIndexList = array of Integer;
TCellIndexes = array of TIndexList; // 你自己的結構:Row * ColumnCount + Column
// 相鄰欄對在使用它的列上找不到寬達
// MinColumnGap / 2 的無文字垂直走廊時,回傳 False
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
MinColumnGap: Double): Boolean;
var
Col, Row, I, LeftCell, RightCell, Supported: Integer;
CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
for Col := 0 to ColumnCount - 2 do
begin
CorridorLeft := -MaxDouble;
CorridorRight := MaxDouble;
Supported := 0;
for Row := 0 to RowCount - 1 do
begin
LeftCell := Row * ColumnCount + Col;
RightCell := LeftCell + 1;
if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
Continue; // 空儲存格不投票
RowLeft := -MaxDouble;
RowRight := MaxDouble;
for I in Cells[LeftCell] do
RowLeft := Max(RowLeft, Words[I].Rect.Right);
for I in Cells[RightCell] do
RowRight := Min(RowRight, Words[I].Rect.Left);
CorridorLeft := Max(CorridorLeft, RowLeft);
CorridorRight := Min(CorridorRight, RowRight);
Inc(Supported);
end;
if (Supported > 0) and
(CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
Exit(False);
end;
Result := True;
end;
別讓有框線的表格闖進空白這一關
如果空白這一關看到頁面上所有詞,有框線表格會被找兩次,因為乾淨的有框線表格按構造同時也是完美對齊的空白表格。放任不管的話,一條「重疊過半」規則會讓一個混搭候選——有框線表格的下排,加上底下幾行對齊文字——剛好低於比率門檻,最後冒出一個滲進鄰居的第二張更大的表
當 DetectRuledTables 與 DetectWhitespaceTables 都開著(預設如此),ExtractTables 會先跑有框線這一關,在空白這一關開始之前,把中心點落在任何有框線表格範圍內的詞全部移除。用中心點而不是完整包含,是讓只壓過邊線零點幾點的詞跟著它視覺上所屬的表格走。這樣空白策略只看到自由詞,緊貼在有框線網格正下方的小型無框表格,按它自己的列被評判
為什麼「Purpose of Request:」出來變成「of Purpose Request:」?
當「同一行」的判斷拿垂直中心比大小時,儲存格裡的詞會亂序輸出,因為由字形框組成的詞框即使共用基線也不共用中心。FPDFText_GetCharBox 回傳的是每個字形墨跡在頁面座標系裡的緊框,不是按字體上伸下延補齊的框,而詞框是各字元框的聯集。像「Purpose of Request:」這樣的表單標籤,「Purpose」和「Request:」帶下延部、「of」不帶,所以「of」的框更矮、中心高出一兩點。一個先按中心 Y 以 1 點同行容差分組、再按左緣排序的常式,會把「of」單獨放上另外兩個詞之上的獨立行、並最先輸出它
PDF 沒有捷徑可抄。ISO 32000-1 §9.4.4 按沿基線的位移擺放字形,檔案裡僅有的垂直度量都屬於字體:Ascent、Descent 與 FontBBox,在字體描述器裡(§9.8.1)。沒有任何東西說兩個字形同一行。讓選取高亮看起來正確的緊框——用 PDFium char box 做文字行選取一文講過——拿來做中心距比較是個爛輸入
PDFium Component 改用重疊來組儲存格文字。一個詞加入某條視覺行的條件是:它與該行累計邊界的垂直重疊達到兩者高度較小者的 25%,或兩個中心相距不超過 1 點;每行內部按左緣排序,行與行之間以 CR LF 對相接。「Purpose」與「of」在整個 x-height 上重疊,遠超過矮框的四分之一,所以它們落進同一行、按順序讀出
在自己的程式碼裡用重疊分組文字行
同樣的陷阱埋伏在任何用中心距對固定容差來判斷「同一行」的 PDF 排版程式碼裡。下延部是最溫和的觸發條件。小號數值旁的粗體標籤、上標註腳記號、帶雜訊的 OCR 框,移動中心的幅度都超過任何「仍能分開 12 點行距上 10 點字相鄰兩行」的容差。重疊比則不在乎大小:同一基線上的兩個框在共用的 x-height 上重疊,相鄰兩行上的兩個框重疊為零
TPdf.PageWordBoxes 回傳使用中頁面上的每個詞,其頁面座標矩形放在 Rect 裡,所以把一頁分組成視覺行只需要一個述詞加一小段迴圈
uses
Math, PDFium;
function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
Overlap, MinHeight: Double;
begin
Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;
procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
Words: TPdfWordBoxes;
Bounds: TArray<TPdfRectangle>; // 每行的累計聯集
I, J, Found: Integer;
begin
Words := Pdf.PageWordBoxes; // 需要使用中頁面(Pdf.PageNumber)
Lines := nil;
Bounds := nil;
for I := 0 to High(Words) do
begin
Found := -1;
for J := High(Lines) downto 0 do
if SameVisualLine(Bounds[J], Words[I].Rect) then
begin
Found := J;
Break;
end;
if Found < 0 then
begin
SetLength(Lines, Length(Lines) + 1);
SetLength(Bounds, Length(Bounds) + 1);
Found := High(Lines);
Bounds[Found] := Words[I].Rect;
end;
SetLength(Lines[Found], Length(Lines[Found]) + 1);
Lines[Found][High(Lines[Found])] := Words[I];
Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
end;
// 讀取前先按 Rect.Left 排序每行:PageWordBoxes 回傳的
// 是內容串流順序的詞,未必等於視覺順序
end;
值得抄的是那個述詞。超出快速傾印的需求,請從結構化文字模型出發——它本來就帶區塊、行與閱讀順序來源,見 帶閱讀順序的結構化 PDF 文字擷取
表格呼叫端還握有哪些控制
表格呼叫端不必碰自己的選項,就能拿到走廊測試、有框線優先的詞過濾與基於重疊的儲存格文字。走廊門檻固定為 MinColumnGap 的一半,所以你為欄溝寬度調的那一個值同時管兩件事。就算 MinRows 設成 1,空白策略仍保有兩列的下限;只有有框線策略接受單列網格,ExtractDocumentTables 用它處理延續到頁首的表格片段。同一份選項記錄也會傳進 TPdfReflowOptions.TableOptions,DetectTables 開著時,重排輸出繼承同樣的決策
走廊測試只對邊界兩側都有內容的列做判決,所以兩列候選只有兩列可判。對以散文為主、鮮少出現真正兩列表格的檔案庫—合約、往來信函、文章—把 MinRows 設成 3,接受犧牲兩列表格。財務與表單密集的文件保持預設 2,因為短的真表格在那裡很常見。右端不齊的文字保持普通單空格,幾乎不可能產出接近 12 點的空隙
PDF 沒有通用的表格物件。ISO 32000-1 §14.8.4.3.4 定義了 Table 結構元素,但只有 Tagged PDF 帶它,其餘一切情況下,網格都是從幾何推論出來的,而空白表格的 Confidence 永遠不超過 0.88
偵測選項、TPdfTable.ToCsv 與 ToJson 序列化,以及供 Delphi、C++Builder 與 Lazarus 使用的 TableExtractionLab 示範,都在 PDFium Component for Delphi 產品頁面上有說明