天天看點

python pdfplumber 打開檔案失敗_pdfplumber是怎麼做表格抽取的(二)

python pdfplumber 打開檔案失敗_pdfplumber是怎麼做表格抽取的(二)

筆者把自己這篇原本釋出在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檔案,從中抽取表格也不是一件容易的事情,概括起來,難在以下幾點:

  1. 與其說pdf是一種資料格式,不如說它是一組列印指令的集合,因為pdf檔案儲存的隻是一條條列印指令,這些指令告訴pdf閱讀器或列印機該在螢幕或者紙張的什麼位置顯示什麼樣的符号。與docx和html等格式的檔案不同(docx和html通過标簽的方式組織不同的邏輯結構,比如<table>, <w:tbl>, <p>, <w:p>等),pdf檔案不包含任何邏輯結構的資訊,比如段落、句子、單詞、表格等等。在pdf文檔中,即便在閱讀器中能看到

    table-like

    的東西,但是卻無法直接有效地把這些視覺上

    table-like

    的東西所對應的資料給抽取出來。
  2. 除了不會儲存邏輯結構資訊之外,pdf往往也不會儲存空格、制表符、回車等不可見字元,是以在pdf中無法像在docx中一樣,通過制表符來定位不是用線框表示的表格。

為了從pdf中比較好的抽取表格,作者調研、嘗試了許多開源的架構(不限于python開發的架構),包括微軟開源的深度學習表格檢測與識别模型TableBank。嘗試了一圈下來,在基于python的架構中,pdfplumber和camelot的效果相對較好。對于線框完全的表格,二者都能給出比較好的抽取效果,但是對于線框不完全(包含無線框)的表格,二者的效果就差了不少。

因為在項目所需處理的pdf文檔中,線框完全及不完全的表格都比較多,是以為了能夠了解pdfplumber實作表格抽取的原理和方法,找到改善、提升表格抽取的方法,作者在這裡對pdfplubmer的代碼邏輯進行了梳理。由于所涉及的内容比較多,是以計劃分為三部分進行整理,分别是:

  1. pdfplumber是怎麼做表格抽取的(一):介紹pdfplumber及其表格抽取流程
  2. pdfplumber是怎麼做表格抽取的(二):梳理pdfplumber表格線檢測邏輯
  3. pdfplumber是怎麼做表格抽取的(三):梳理pdfplumber表格生成邏輯

本文是第二部分。

得到定義表格的“邊”

pdfplumber用三種不同的方式确定pdf文檔中可能存在的表格線,分别是:

  1. 把可見的線作為候選表格線,這種方式一般用于抽取線框完全的表格。
  2. 根據文本的對齊狀态,猜測可能的表格線,這種方式一般用于線框不完全的表格。
  3. 額外制定表格線,用于輔助線框不完全表格的抽取。

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

的邏輯:

  1. 根據words的頂部位置進行聚類,聚類結果應該是把words放到了不同的文本行當中。
  2. 篩選掉那些包含word少于word_threshhold的文本行
  3. 把剩下文本行的頂部和底部邊緣線作為找到的邊傳回。
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

總體類似,差別主要包含以下幾方面:

  1. 同時用words的左、右和中心位置進行聚類,把words放到不同的列塊中。
  2. 對不同對齊方式得到文本列按照包含的word數目進行排序,并删除那些word數目低于word_threshold的列。
  3. 去除掉一些有互相重疊的列塊
  4. 通過最右邊的列塊确定最右邊的邊界
  5. 把剩下列塊的左邊界和最右邊列塊的右邊界作為找到的邊傳回。

額外指定的邊

對于線框不完全的表格,如果表格檢抽取效果不佳,pdfplumber支援在用

pdfplumber.page.Page

類中的

find_tables

extract_tables

等方法抽取表格的時候,從外部指定一些水準或豎直的線,以提升表格抽取的效果。

合并找到的邊

通過上面的方法,可能會找到很多線段,其中存在不少的備援:

  1. 某些平行線之間的垂直距離非常小,需要對它們進行對齊,讓他們位于同一條直線上,pdfplumer使用平均位置進行對齊。
  2. 對于同一直線上的某些線段,互相之間鄰近端點的距離非常小,這種情況,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
           

好了,這部分就到這裡啦 ^_^