技術文章

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

PDFium Component 3.117.0 版不再把左右對齊的段落回報成空白對齊的表格,做法是要求每一道欄界線都是一條在被它分隔的每一列上都沒有文字的垂直走廊、跳過已經被有框線網格取用的詞,並且改用垂直重疊而不是字符方框中心距離來組出儲存格文字。這三項改動都在 ExtractTables 與 ExtractDocumentTables 裡面,不需要任何選項

把事情引爆的那份回報很不起眼。一頁根本沒有表格的新聞稿頁面,從 ExtractTables 回來時帶著一張 5x4 的空白表格,信心值還穩穩高於預設 MinConfidence 的 0.5,而儲存格裡裝的是普通內文的片段。一份入學表單用它的論說段落做了同樣的事,產出 3x4 與 5x3 各一張。兩份文件都是左右對齊。最直覺的反應是去調門檻,而這次發行有用的教訓是:調參數救不了,因為被調的那條規則問錯了問題

uses
  PDFium;

// 迴歸檢查:列出文件裡每一張空白表格,好確認一頁
// 您明知只有散文的頁面是乾淨的
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  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,偵測器就沒有任何只看單列的辦法能把它與欄分隔區分開。PDFium Component 的空白策略把文字方框組成視覺列,只要與前一個詞的水平距離達到 MinColumnGap(預設 12 點)就把該列切成詞組,並在至少連續兩列、每列重複至少 MinColumns 個左對齊的群組錨點且落在 AlignmentTolerance(也就是 3 點)之內時,認定這是一張表格。那就是表格偵測總覽所述的規則,而對真正對齊的表格來說它完全正確

現在把它套到二十行左右對齊的 10 點散文上。每一行都被拉到同一個右邊界,所以一行若以長詞結尾,就會把內部的空白拉開,而在一段有幾行較短的段落裡,其中一些空白會超過 12 點。連續兩行只要各有一個被拉開的間隙、又落在同一個 X 位置 3 點之內,就構成一個兩列兩欄的候選。行數一多,這不是運氣不好;這是一個趨近於必然的機率,而那份新聞稿上的 5x4 不過是剛好有四道這樣的間隙在五行之上對齊的那一次

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

每一個門檻都是在拿一類文件換另一類。把 MinColumnGap 提到 20 點會弄丟密集財務報表裡的窄欄,而那正是當初把預設值調低的理由。把 MinRows 提到 3 會丟掉真正的兩列表格,而且對長段落只是降低機率。把 AlignmentTolerance 收緊到 3 點以下會弄壞 OCR 來的文字方框,它們的左緣抖動幅度比這更大。列層級的訊號本身就是模糊的,所以修正必須來自一個列本身無法攜帶的訊號

什麼讓一道欄界線成真?

一道真正的欄界線,是頁面上某條在被它分隔的每一列上都保持空白的垂直條帶。表格的每一對欄之間依構造就有這樣一條,因為那些儲存格是對著共用的 X 位置排出來的。左右對齊的段落則在每一行把詞間空白拉在不同水平位置,所以沒有哪一條條帶能撐過一行以上的交集。PDFium Component 現在測的就是這件事:候選的詞組被指派到錨點欄之後,對每一對相鄰的欄,它在每一列於兩格都有內容的列上,取出從左格文字最右緣到右格文字最左緣的區間,把這些區間對所有列取交集,而若交集比 MinColumnGap 的 0.5 倍還窄(預設值下是 6 點),就否決整個候選

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

有兩個細節要緊。任一格為空的列不投票,所以一張有空白儲存格的表格、或表頭跨欄數比內文少的表格,仍然過關。而走廊寬度是從 MinColumnGap 推導出來的,不是另外開一個選項,因為這兩者描述的是同一件實體上的東西:設計者在欄與欄之間留下的間隙。如果您是建在原始文字方框上、而不是用表格 API,這段邏輯小到可以照抄;下面的範例就是元件內部那道檢查的翻版:

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;

為什麼有框線的表格會被抽兩次?

有框線的表格會被抽兩次,是因為空白那一趟以前會看到頁面上每一個詞,包括有框線那一趟早就擺進網格裡的詞,而一張乾淨的有框線表格依構造也同時是一張完美對齊的空白表格。當時已經有一道重疊檢查會否決界限覆蓋既有表格一半以上的空白候選,但一個把表格底下幾列與其下方幾行對齊文字併在一起的候選,可能落在那個比例之下而存活成第二張、稍微大一點、還滲進鄰居的表格。ExtractTables 現在會在空白那一趟開跑前把那些詞移除。當一個詞的中心點落在有框線那一趟產出的任何一張表格界限之內時,該詞就會被丟掉;用中心點而不是完全包含,是為了讓一個只壓到邊框零點幾點的詞,跟著它視覺上屬於的那張表格走。空白策略接著只在剩下的自由詞上工作,這也意味著一張緊貼在有框線表格底下的小型無框線表格,會依它自己的條件被偵測到,而不是跟上面的網格黏在一起

為什麼「Purpose of Request:」出來變成「of Purpose Request:」?

詞的順序會跑掉,是因為 PDFium Component 建出的文字方框是字符邊界框的聯集,而 "of" 沒有下伸部,但 "Purpose" 與 "Request:" 有。FPDFText_GetCharBox 回傳的是字符墨水在頁面空間裡的緊貼方框,不是補到字型 ascent 與 descent 的方框,而文字方框就是它各個字元的方框的聯集。因此沒有下伸部的詞比較矮,垂直中心也偏高——在那份表單上是 2 到 3 點。舊的儲存格文字常式先按中心 Y 排序(「同一行」的容差是 1 點),再按左緣排序;"of" 超出了那個容差,被排成它自己的一行、落在其他人上方,於是最先被輸出

這算不上 PDFium 的怪癖,更像是 PDF 定位文字方式的必然結果。ISO 32000-1 §9.2.2 與 §9.4.4 把字符定位定義為文字空間裡沿基線的水平位移,而檔案唯一攜帶的垂直度量是逐字型的:§9.8.1 字型描述項裡的 Ascent、Descent 與 FontBBox。檔案裡沒有任何東西說兩個字符共用同一行;那必須從幾何推論,而讓選取反白看起來正確的那些緊貼字符方框——用 PDFium 字符方框做文字行選取一文談過——正是中心距離比較所不能用的輸入

3.117.0 版的修正把問題從「中心相距多遠」換成「方框垂直重疊多少」。儲存格文字的組法是先把該格的詞聚成視覺行——當一個詞與該行累積界限的垂直重疊至少是兩個高度中較小者的 25% 時,它就加入這一行——接著用插入排序把每一行按左緣排好,再用換行把各行接起來。"Purpose" 與 "of" 在整個 x-height 上重疊,遠超過較短方框的 25%,所以它們落在同一行,並如預期地按 X 排序

PDFium Component 中 Purpose of Request 順序修正的示意:來自 FPDFText_GetCharBox 的緊貼字符方框讓沒有下伸部的 of 擁有較高的中心,舊的 1 點中心 Y 容差因此把它排成自己一行;而 25% 垂直重疊規則把它留在基線上,恢復了詞序
中心 Y 會隨著墨水剛好帶有哪些上伸部與下伸部而移動,而同一基線上的兩個方框,不管各自多高,都會在共用的 x-height 上重疊

用重疊、而不是中心距離來聚文字行

這個 bug 值得帶走的規則很普遍:任何用固定容差比較垂直中心來判定「同一行」的 PDF 文字排版程式碼,在真實字型上都會失敗,而且失敗是無聲的——不會報錯,詞就只是以錯的順序出來。下伸部混雜是最輕微的觸發條件。一個 12 點粗體標籤旁邊放 10 點的數值、一個上標的註腳標記、一個來自備援字型的貨幣符號,以及逐詞高度帶雜訊的 OCR 文字方框,都會把中心移動得比任何「仍能分開行距 12 點的 10 點文字相鄰兩行」的容差更遠。重疊比例則與大小無關:同一基線上的兩個方框,不管各自的上伸部與下伸部如何,都會在共用的 x-height 上重疊;而相鄰兩行的兩個方框,重疊量是零

這條規則在表格抽取之外也很好套用。TPdf.PageWordBoxes 會回傳作用中頁面上每一個詞及其頁面空間矩形,所以把一頁聚成視覺行只是一小段迴圈:

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;
  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 時仍保有兩列地板(而有框線策略現在接受 1);有框線優先的詞過濾則在兩種策略都啟用時無條件執行。在這次發行所用的 13 份文件樣本集上,空白那一趟先前在 9 張有框線表格之外還回報了 34 筆碎片與誤判;發行之後它一筆都不回報,而有框線表格的數量升到 41,不過這個增幅大多來自同一次發行教會了有框線偵測器讀取以填色矩形畫出的框線,那是另一段故事

誠實的界線:走廊測試至少需要有一列在界線兩側都有內容才否決得了什麼,所以一個兩列候選、其兩道被拉開的間隙剛好落在彼此 6 點之內,還是照樣過關。那是一個狹窄的巧合,而不是以前那種近乎必然,但沒有真正兩列表格的散文型文件可以靠把 MinRows 設成 3 來堵住它。左對齊、參差不齊的文字從來不是問題,也不受影響。而 PDF 仍然沒有表格物件;ISO 32000-1 §14.8.4.3 定義了 Table 結構元素,但只有 Tagged PDF 才帶著它,所以對其他一切而言,網格仍然是從幾何推論出來的,而每個 TPdfTable 上的信心值之所以存在,正是因為推論值得給個分數

表格抽取、結構化文字與文字方框,在 Delphi、C++Builder 與 Lazarus 上都讀同一個頁面模型;完整 API,包含 TPdfTableExtractionOptions 與隨附的 TableExtractionLab 範例,說明在 PDFium Component for Delphi 產品頁