iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

先做一個實驗:拿一份 A 股年報,翻到資產負債表那一頁,用任何一個 PDF 轉文字的工具跑一遍。

你看到的不是表格。你看到的是一長串按座標排好、但沒有分行也沒有分欄的文字——「貨幣資金 120,624,688.07 16.81% 52,242,476.82 7.90%」全部黏在一起。

這篇要解決的就是這件事:把這些散落的文字片段,還原成行列分明的表格。

PDF 的繪圖模型裡沒有「表格」

根本原因在 PDF 的格式本身。

PDF 的內容是一連串繪圖指令:移動到某個座標、畫一條線、畫一個矩形、在某個座標放一段文字。它沒有表格這個概念——沒有列、沒有欄、沒有儲存格。

所謂「表格」,在檔案裡實際上是三種東西的疊合:

  1. 若干條水平線
  2. 若干條垂直線
  3. 一堆各自獨立的文字片段,每個片段帶著自己的座標

換句話說:表格是「看起來像」的,不是「標記成」的。 這是坑一的根源,也是為什麼現成的文字抽取工具在這裡一定失手——它們沒有東西可以依據。

第一步:把線撈出來

PyMuPDF 的 page.get_drawings() 會回傳頁面上所有的向量圖形。每個圖形是一組 items,而每個 item 的第一個元素是原語類型。實際跑一遍,會看到兩種東西在畫線:

import fitz

doc = fitz.open("annual_report.pdf")
page = doc[12]

for d in page.get_drawings():
    for item in d["items"]:
        print(item[0], item[1])
# re Rect(56.7, 120.3, 540.1, 120.9)         <- 細長矩形,也算一條線
# l  Point(56.7, 130.1) Point(540.1, 130.1)  <- 線段

這就是第一個關鍵:同樣是視覺上的一條橫線,PDF 裡可以有兩種完全不同的寫法——矩形原語(re)和線段原語(l)。這個差異會在 Day 10 變成命中率從 47% 到 96% 的分歧點。

能跑的偵測版本長這樣:

def detect_hlines(page):
    """回傳 [(y, x0, x1)]"""
    hlines = []
    for d in page.get_drawings():
        width = d.get("width") or 0        # 有些 drawing 的線寬是 None
        for item in d["items"]:
            if item[0] == "re":            # 細長矩形
                r = item[1]
                if r.height < 1.5 and r.width > 50:
                    hlines.append((r.y0, r.x0, r.x1))
            elif item[0] == "l":           # 線段
                p1, p2 = item[1], item[2]
                y0, y1 = min(p1.y, p2.y), max(p1.y, p2.y)
                x0, x1 = min(p1.x, p2.x), max(p1.x, p2.x)
                if y1 - y0 < 1.0 and x1 - x0 > 50 and width < 1.5:
                    hlines.append((y0, x0, x1))
    return sorted(hlines)

三個門檻值都有道理:

  • 高度(或線段的 y 差)< 1.5 pt:把「一條線」和「一個有面積的色塊」分開。表格框線在 PDF 裡的高度幾乎是 0。
  • 長度 > 50 pt:濾掉短橫線——負號、破折號、文字底線都會被誤認成表格線。
  • 線寬 < 1.5 pt:濾掉粗框線和裝飾線。

第二步:把文字歸位

行線有了,接下來要決定「每個文字片段屬於哪一列、哪一欄」。

words = page.get_text("words")   # 每筆 = (x0, y0, x1, y1, 文字, ...)

get_text("words") 回傳的是詞 + 邊界框。歸位的判斷用中心點:

  • cy = (y0 + y1) / 2 落在哪兩條行線之間 → 就是那一列
  • cx = (x0 + x1) / 2 落在哪兩條欄線之間 → 就是那一欄

用中心點,不要用左上角。 原因是跨欄的儲存格——「合計」「其中:」這類文字常常從最左邊界開始寫,用左上角判會把它整段塞進第一欄。

線跟線的聚類容差用 3.0 pt:同一條視覺上的線,在不同繪圖指令裡 y 座標可能差零點幾,不聚在一起會被當成兩條線。

還有一個不能省的順序問題:要先把表格區域框出來,再過濾文字。 表格外面的段落文字如果落在同一組行線的 y 範圍內,也會被吸進格子裡。

這個坑真正的代價:沒有正確答案

技術上就是這些。但心理上這是最磨人的一段,原因只有一個:表格抽錯了不會報錯。

它不會拋例外、不會中斷、不會讓排程亮紅燈。它只會少給你幾行、或把兩欄數字併成一欄。輸出永遠看起來像一份文件,只是不完全是原本那一份。

所以這個坑逼你做的,其實是另一件事:你必須自己準備一份人工核對過的答案卷。 拿一份已經有人看過的財報,把每一張表的行列數、關鍵科目、數字對一遍,才知道自己改的那一行程式到底有沒有用。

這件事沒有捷徑。但它也是我後來每次改動解析器之前都會先做的事——先有答案卷,再改程式。

明天講偵測規則實際走過的兩個轉折,以及那個 47% 是怎麼被發現的。


本文為 2026 iThome 鐵人賽連載第 9 篇。系列主題:把東亞財報 PDF 煉成 LLM 讀得懂的 Markdown。


上一篇
財報 PDF 為什麼這麼難?四個坑的總覽
系列文
30 天打造東亞財報全文 API:PDF 解析、亂碼修復、章節切分到 MCP 上線實錄 共 9 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言