筆者把自己這篇原本釋出在github page上的文章遷移到了這裡,原github page網址:https://iceflameworm.github.io/2019/12/03/pdfplumber-table-extraction-2/
pdfplumber是一款完全用python開發的pdf解析庫,對于線框完全的表格,pdfminer能給出比較好的抽取效果,但是對于線框不完全(包含無線框)的表格,其效果就差了不少。因為在實際項目所需處理的pdf文檔中,線框完全及不完全的表格都比較多,是以為了能夠了解pdfplumber實作表格抽取的原理和方法,找到改善、提升表格抽取效果的方法,這裡對pdfplubmer的代碼邏輯進行了梳理。由于所涉及的内容比較多,是以計劃分為三部分進行整理:1. 介紹pdfplumber及其表格抽取流程, 2. 梳理pdfplumber表格線檢測邏輯, 3. 梳理pdfplumber表格生成邏輯。本文是第二部分。
- 背景介紹
- 得到定義表格的“邊”
- 看得見的邊
- 看不見的邊
- 額外指定的邊
- 合并找到的邊
- 找到相交的點
背景介紹
最近在做一個表格資訊抽取的項目,該項目需要從pdf檔案中找到的目标表格,并把目标表格中需要的行和列給抽取出來。由于項目中pdf掃描件占比相對較少(不太到10%吧),是以目前主要把精力花在可編輯pdf檔案的表格抽取上。
即便是可編輯的pdf檔案,從中抽取表格也不是一件容易的事情,概括起來,難在以下幾點:
- 與其說pdf是一種資料格式,不如說它是一組列印指令的集合,因為pdf檔案儲存的隻是一條條列印指令,這些指令告訴pdf閱讀器或列印機該在螢幕或者紙張的什麼位置顯示什麼樣的符号。與docx和html等格式的檔案不同(docx和html通過标簽的方式組織不同的邏輯結構,比如<table>, <w:tbl>, <p>, <w:p>等),pdf檔案不包含任何邏輯結構的資訊,比如段落、句子、單詞、表格等等。在pdf文檔中,即便在閱讀器中能看到
的東西,但是卻無法直接有效地把這些視覺上table-like
的東西所對應的資料給抽取出來。table-like
- 除了不會儲存邏輯結構資訊之外,pdf往往也不會儲存空格、制表符、回車等不可見字元,是以在pdf中無法像在docx中一樣,通過制表符來定位不是用線框表示的表格。
為了從pdf中比較好的抽取表格,作者調研、嘗試了許多開源的架構(不限于python開發的架構),包括微軟開源的深度學習表格檢測與識别模型TableBank。嘗試了一圈下來,在基于python的架構中,pdfplumber和camelot的效果相對較好。對于線框完全的表格,二者都能給出比較好的抽取效果,但是對于線框不完全(包含無線框)的表格,二者的效果就差了不少。
因為在項目所需處理的pdf文檔中,線框完全及不完全的表格都比較多,是以為了能夠了解pdfplumber實作表格抽取的原理和方法,找到改善、提升表格抽取的方法,作者在這裡對pdfplubmer的代碼邏輯進行了梳理。由于所涉及的内容比較多,是以計劃分為三部分進行整理,分别是:
- pdfplumber是怎麼做表格抽取的(一):介紹pdfplumber及其表格抽取流程
- pdfplumber是怎麼做表格抽取的(二):梳理pdfplumber表格線檢測邏輯
- pdfplumber是怎麼做表格抽取的(三):梳理pdfplumber表格生成邏輯
本文是第二部分。
得到定義表格的“邊”
pdfplumber用三種不同的方式确定pdf文檔中可能存在的表格線,分别是:
- 把可見的線作為候選表格線,這種方式一般用于抽取線框完全的表格。
- 根據文本的對齊狀态,猜測可能的表格線,這種方式一般用于線框不完全的表格。
- 額外制定表格線,用于輔助線框不完全表格的抽取。
TableFinder
類中的
get_edges
方法把上述三種不同的方式都包含在内,可以通過配置進行選擇,具體如何選擇這裡就不詳細介紹了,感興趣的讀者可以參考pdfplumber自身的配置指引。
看得見的邊
對線框完全的表格,整個表格和各個單元格的邊界都是可以用矩形線框表示和區分開來,是以要檢測和解析這類表格,可以先把那些可見的、有可能作為表格線的線找出來。
在pdfplumber中,找出可見的線相對比較簡單,因為pdfplumber底層是基于pdfminer的,而pdfminer能夠把pdf文檔中的水準、豎直的線給解析出來。需要注意的是:1. pdfminer會解析出很多非常短、肉眼基本看不出的線框;2. 可見的線框不能位于圖像對象中。
當用
pdfplumber.open
打開pdf文檔後,會通過pdfminer對打開的文檔進行解析,每一頁解析的結果會儲存在
pdfplumber.page.Page
類的執行個體對象中。
Page
類是
pdfplumber.container.Container
子類,
Container
類定義了通路chars、rects、edges等基本對象的property,是以可以通過
Page
執行個體對象本身友善的通路到對應頁面解析出的相關對象。
TableFinder
類中的
get_edges
方法通過
utils
子產品中的
filter_edges
函數對每一個
Page
執行個體對象中的解析出的edges對象進行篩選和過濾,過濾條件包括:方向、最小長度等。
看不見的邊
對于線框不完全的表格(包括無線框表格),在表格和某些單元格的四周并沒有完整的、可見的表格線表示它們的邊界和範圍。人在檢測、識别這類表格的時候,似乎不費吹灰之力,但是對計算機而言,僅靠一堆字元以及它們對應的位置資訊,似乎就不是那麼得心應手了。
如果仍然要先把确定表格和單元格的表格線找出來的話,那麼這個時候是沒有從pdf文檔中直接解析出的可見線框用的。pdfplumber是怎麼應對這種情況的呢?它根據文本的對齊情況猜測出一些水準和豎直的線,這些線被稱作“
Text Edge”,并利用這些線進一步猜測出表格以及單元格的邊界,實作表格抽取的目的。
TableFinder
類的
get_edges
方法通過調用同子產品中的
words_to_edges_v
和
words_to_edges_h
,根據每一頁中解析出的words(
word指的應該是由每一行上彼此間距較小的字元合成的連續字元串)的對齊情況,猜測出豎直方向和水準方向上可能存在的線。
下面是
words_to_edges_h
函數的代碼,從中比較容易看出其尋找水準
Text Edge的邏輯:
- 根據words的頂部位置進行聚類,聚類結果應該是把words放到了不同的文本行當中。
- 篩選掉那些包含word少于word_threshhold的文本行
- 把剩下文本行的頂部和底部邊緣線作為找到的邊傳回。
def words_to_edges_h(words,
word_threshold=DEFAULT_MIN_WORDS_HORIZONTAL):
"""
Find (imaginary) horizontal lines that connect the tops of at least `word_threshold` words.
"""
by_top = utils.cluster_objects(words, "top", 1)
large_clusters = filter(lambda x: len(x) >= word_threshold, by_top)
rects = list(map(utils.objects_to_rect, large_clusters))
if len(rects) == 0:
return []
min_x0 = min(map(itemgetter("x0"), rects))
max_x1 = max(map(itemgetter("x1"), rects))
edges = [ {
"x0": min_x0,
"x1": max_x1,
"top": r["top"],
"bottom": r["top"],
"width": max_x1 - min_x0,
"orientation": "h"
} for r in rects ] + [ {
"x0": min_x0,
"x1": max_x1,
"top": r["bottom"],
"bottom": r["bottom"],
"width": max_x1 - min_x0,
"orientation": "h"
} for r in rects ]
return edges
因為
words_to_edges_v
的代碼較多,這裡就不貼了。其實作邏輯跟
words_to_edges_h
總體類似,差別主要包含以下幾方面:
- 同時用words的左、右和中心位置進行聚類,把words放到不同的列塊中。
- 對不同對齊方式得到文本列按照包含的word數目進行排序,并删除那些word數目低于word_threshold的列。
- 去除掉一些有互相重疊的列塊
- 通過最右邊的列塊确定最右邊的邊界
- 把剩下列塊的左邊界和最右邊列塊的右邊界作為找到的邊傳回。
額外指定的邊
對于線框不完全的表格,如果表格檢抽取效果不佳,pdfplumber支援在用
pdfplumber.page.Page
類中的
find_tables
和
extract_tables
等方法抽取表格的時候,從外部指定一些水準或豎直的線,以提升表格抽取的效果。
合并找到的邊
通過上面的方法,可能會找到很多線段,其中存在不少的備援:
- 某些平行線之間的垂直距離非常小,需要對它們進行對齊,讓他們位于同一條直線上,pdfplumer使用平均位置進行對齊。
- 對于同一直線上的某些線段,互相之間鄰近端點的距離非常小,這種情況,pdfplumber會把它們合并成一個線段。
pdfplumber.table.TableFinder
類的
get_edges
方法會調用同一子產品下的
merge_edges
函數實作上述功能。下面是
merge_edges
的代碼:
def merge_edges(edges, snap_tolerance, join_tolerance):
"""
Using the `snap_edges` and `join_edge_group` methods above, merge a list of edges into a more "seamless" list.
"""
def get_group(edge):
if edge["orientation"] == "h":
return ("h", edge["top"])
else:
return ("v", edge["x0"])
if snap_tolerance > 0:
edges = snap_edges(edges, snap_tolerance)
if join_tolerance > 0:
_sorted = sorted(edges, key=get_group)
edge_groups = itertools.groupby(_sorted, key=get_group)
edge_gen = (join_edge_group(items, k[0], join_tolerance)
for k, items in edge_groups)
edges = list(itertools.chain(*edge_gen))
return edges
merge_edges
函數分别調用同子產品下的
snap_edges
和
join_edge_group
函數進行平行線的對齊以及同一直線上線段的合并。
找到相交的點
因為文檔中的表格以及表格單元格基本上都是矩形的,而矩形是可以由其頂點确定的,是以,在找到那些可能是表格或單元格邊界的線之後,接下來是找出它們的交點。下面就是
pdfplumber.table
子產品中
edges_to__intersections
函數的代碼,用于找到水準線與豎直線之間的交點,最終的傳回的結果是一個字典,以交點坐标作為key,value中儲存的是相交于該交點的線。
def edges_to_intersections(edges, x_tolerance=1, y_tolerance=1):
"""
Given a list of edges, return the points at which they intersect within `tolerance` pixels.
"""
intersections = {}
v_edges, h_edges = [ list(filter(lambda x: x["orientation"] == o, edges))
for o in ("v", "h") ]
for v in sorted(v_edges, key=itemgetter("x0", "top")):
for h in sorted(h_edges, key=itemgetter("top", "x0")):
if ((v["top"] <= (h["top"] + y_tolerance)) and
(v["bottom"] >= (h["top"] - y_tolerance)) and
(v["x0"] >= (h["x0"] - x_tolerance)) and
(v["x0"] <= (h["x1"] + x_tolerance))):
vertex = (v["x0"], h["top"])
if vertex not in intersections:
intersections[vertex] = { "v": [], "h": [] }
intersections[vertex]["v"].append(v)
intersections[vertex]["h"].append(h)
return intersections
好了,這部分就到這裡啦 ^_^