技術文章

修正 Delphi 中左右對齊文字造成的 PDF 假表格

一頁左右對齊的內文,可能被空白表格偵測器回報成好幾欄的網格,每個儲存格裡放著半截句子,信心分數遠高於你會想到去設的任何門檻。那頁根本沒有表格。偵測器找到的,是寬到可以冒充欄分隔的詞間空隙,加上幾行空隙幾乎落在同一個 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)裡的數值調整來定位詞;兩種做法得到的詞框一模一樣。每段的最後一行照末尾不齊排、帶普通空格,這正是為什麼誤判往往蓋住段落主體、卻在段落結束前一行停下

PDFium Component 中為什麼左右對齊的散文會被算成表格的示意:每一行都被拉到同一個邊界,所以單一間隙在每一行跨越 MinColumnGap 的 X 都不同,而連續兩道落在 AlignmentTolerance 之內的間隙就造出了假候選,如今被走廊測試擋掉
真正的表格會在每一列重複它的欄錨點,而左右對齊的段落每一行拉開的是不同的空白;這就是為什麼只靠列層級的調參分不開這兩者

快速檢查自己語料的方法:對你確定只有散文的文件(信件、新聞稿之類)跑一次偵測器,把它回報的每個空白模式表格印出來。這些文件上的預期輸出是零

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 點,整個候選就會被拒收

PDFium Component 中 ExtractTables 背後那道無文字走廊測試的示意:每一列捐出從左格右緣到右格左緣的區間;在真正的表格裡交集仍比 MinColumnGap 的一半更寬,在左右對齊的文字裡則塌縮成零
真正的欄界線在被它分隔的每一列上都是空的,所以把逐列間隙取交集之後,表格還留著一條共用條帶,被拉開的散文則一條都不剩

在彆扭的真表格上有兩個細節。某一側儲存格為空的列不投票,所以帶空白儲存格的表格、或欄數比主體少的表頭列,照樣通過。走廊寬度是從 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 中 Purpose of Request 順序修正的示意:來自 FPDFText_GetCharBox 的緊貼字符方框讓沒有下伸部的 of 擁有較高的中心,舊的 1 點中心 Y 容差因此把它排成自己一行;而 25% 垂直重疊規則把它留在基線上,恢復了詞序
中心 Y 會隨著墨水剛好帶有哪些上伸部與下伸部而移動,而同一基線上的兩個方框,不管各自多高,都會在共用的 x-height 上重疊

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 產品頁面上有說明