0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python で Word 文書の本文と表を抽出する

0
Posted at

Word 文書の内容は連続する本文だけでなく、表、見出し、しおりなどの構造を含み得る。用途に応じて、純テキスト、構造化データ、指定区間の内容としてそれぞれ抽出する必要がある。本文は検索庫に入れ、表は CSV にして集計し、見出しは目次索引にし、しおりは主要区間の取り出しに使う。手作業で文書ごとにコピー&ペーストすると遅く、行の漏れも出やすい。これらの抽出をスクリプトに固定すれば、同一の文書群から分流して異なる産物を出力できる。

Python を使えば、docx から次の要素を取り出せる。section.Paragraphs を走査して本文テキスト、section.Tables を走査して表の行列データ、paragraph.StyleName で見出しを絞り込み、しおり名で BookmarksNavigator を使って任意の命名区間の内容を取得する。

一括処理の演示のため、本記事では構造が相似する 3 件の技術サービス契約書をサンプルに使う。各件に Heading1 見出し 4 つ(契約書タイトル、支払条項、納品条項、その他の定め)があり、見出しの下に本文段落が続く。支払条項と納品条項の 2 節にはそれぞれ表 1 つ(支払計画 3 列、納品物明細 2 列)があり、同名のしおりで説明段落と表を囲む。3 件は関東・関西・九州のプロジェクトに対応し、支払段階は 3〜4 条である。文書の実物は記事ディレクトリに置き、1 ページ目の様子を次に示す。

サンプル契約書 1 ページ目:見出し・本文・支払計画表

本記事の手法には Free Spire.Doc for Python が必要で、pip で導入できる。

pip install spire.doc.free

Free 版は 1 文書あたりの段落数と表数に上限があり、本記事のサンプルは各契約書 2 ページ程度・表 2 件で影響を受けない。上限を超える長文は商用版が必要である。

1. 本文テキストの抽出

本文の抽出は各セクションのトップレベル段落を走査し、paragraph.Text を取得してテキストファイルに書き出す。段落 1 つを 1 行とし、段落順序は文書と一致する。

from spire.doc import *
from spire.doc.common import *

def extractText(docxPath):
    doc = Document()
    doc.LoadFromFile(docxPath)
    lines = []
    for i in range(doc.Sections.Count):
        section = doc.Sections.get_Item(i)
        for j in range(section.Paragraphs.Count):
            para = section.Paragraphs.get_Item(j)
            lines.append(para.Text)
    doc.Close()
    return lines

サンプル契約書に対して実行すると、17 段落・計 366 文字を取得し、正文_関東データ.txt に書き出す。その内容は次の通り。

正文_関東データ.txt

ここでオブジェクトモデル上の境界が 1 つある。section.Paragraphs はセクション直下のトップレベル段落のみを含み、表内部の段落は含まれない。Word 文書では表は Body の子オブジェクトで段落と同階層にあり、表内部の段落は表に帰属しセクションには帰属しない。そのため本文の抽出で得られるのは文書のトップレベル段落だけで、表の内容は重複して現れない。本文と表は同階層の 2 種類の構造で、それぞれ走査を分ける必要がある。全文を 1 本のテキストにして検索や大規模言語モデルに投入する場合は、doc.GetText() で表を含むすべてのテキストをそのまま取得できる。

2. すべての表の抽出

表の抽出は section.Tables を走査し、表ごとに行・セル単位でテキストを取得する。セル内のテキストは cell.Paragraphs を走査して結合する。1 つのセルに複数段落(セル内改行)が含まれることがあり、最初の段落だけを取ると行を取りこぼす。

def cellText(cell):
    parts = []
    for k in range(cell.Paragraphs.Count):
        parts.append(cell.Paragraphs.get_Item(k).Text)
    return " ".join(parts)

def extractTables(docxPath):
    doc = Document()
    doc.LoadFromFile(docxPath)
    tables = []
    for i in range(doc.Sections.Count):
        section = doc.Sections.get_Item(i)
        for t in range(section.Tables.Count):
            table = section.Tables[t]
            rows = []
            for r in range(table.Rows.Count):
                row = table.Rows[r]
                rows.append([cellText(row.Cells[c]) for c in range(row.Cells.Count)])
            tables.append(rows)
    doc.Close()
    return tables

# CSV に書き出す
outDir   = "output_csv"
os.makedirs(outDir, exist_ok=True)

for i, table in enumerate(extractTables(docxPath), 1):
    path = os.path.join(outDir, f"{os.path.splitext(os.path.basename(docxPath))[0]}_{i:02d}.csv")
    with open(path, "w", newline="", encoding="utf-8-sig") as f:
        csv.writer(f).writerows(table)
    print("書き出し:", path)

サンプル契約書には表 2 つがある。支払計画 4 行 × 3 列、納品物明細 4 行 × 2 列。表ごとに csv 1 ファイルを書き出し、セルの区切りにはタブを使う。カンマではなくタブを選ぶのは、説明欄に読点やカンマが含まれるため、カンマを区切りにすると列の境界が壊れるからである。支払計画表の内容は次の通り。

抽出した支払計画表

3. スタイル別に見出しを抽出

Word は Heading1、Heading2 などの内蔵スタイルで章見出しを記す。スタイル名で段落を絞り込めば、見出しを取り出して目次索引を作れる。アーカイブ時に各文書の先頭に付ければ位置確認に便利である。

def extractByStyle(docxPath, styleName):
    doc = Document()
    doc.LoadFromFile(docxPath)
    items = []
    for i in range(doc.Sections.Count):
        section = doc.Sections.get_Item(i)
        for j in range(section.Paragraphs.Count):
            para = section.Paragraphs.get_Item(j)
            if para.StyleName is not None and para.StyleName == styleName:
                items.append(para.Text)
    doc.Close()
    return items

Heading1 をスタイル名に実行すると、4 つの見出しを得る。北星テクノロジー技術サービス契約書、支払条項、納品条項、その他の定め。スタイル名は Spire が Word 内蔵スタイルに付ける内部名で、Heading1 は Word の「見出し 1」に対応する。文書が独自のスタイル名を使う場合は、まず全量走査で para.StyleName をすべて印刷して実際の値を確認し、それから絞り込む。

4. しおり別に指定内容を抽出

しおりの抽出は「文書の特定の区間の内容だけを取る」用途に使う。文書内でしおりが区間を記し、BookmarksNavigator でしおりに定位した上で内容を取得する。サンプル契約書では支払条項と納品条項の 2 節がそれぞれ同名のしおりで説明段落と表を囲む。

def extractBookmark(docxPath, bookmarkName):
    doc = Document()
    doc.LoadFromFile(docxPath)
    navigator = BookmarksNavigator(doc)
    navigator.MoveToBookmark(bookmarkName)
    textBodyPart = navigator.GetBookmarkContent()
    parts = []
    for i in range(textBodyPart.BodyItems.Count):
        item = textBodyPart.BodyItems.get_Item(i)
        if isinstance(item, Paragraph):
            para = item
            buf = []
            for j in range(para.ChildObjects.Count):
                child = para.ChildObjects.get_Item(j)
                if isinstance(child, TextRange):
                    buf.append(child.Text)
            parts.append("".join(buf))
        elif isinstance(item, Table):
            tbl = item
            lines = []
            for r in range(tbl.Rows.Count):
                row = tbl.Rows[r]
                lines.append("\t".join(cellText(row.Cells[c]) for c in range(row.Cells.Count)))
            parts.append("\n".join(lines))
    doc.Close()
    return "\n".join(parts)

ここには実際に走らないと気付かない細部がある。しおりの区間に表が含まれる場合、GetBookmarkContent が返す BodyItems には Paragraph と Table の両方が入る。Paragraph だけ判定すると表を取りこぼす。支払条項のしおりは段落のみ取得で 41 字(説明の 1 文のみ)、Table 分岐を補うと 132 字になり、説明段落と支払計画表全体が入る。納品条項も同様に、28 字から 132 字に増える。しおり抽出で 1 区間を完全に取るには、2 種類の BodyItem をそれぞれ処理しなければならない。

支払条項しおりの抽出結果は次の通り。

支払条項しおりの抽出結果

5. 複数の Word 文書の一括処理

単一文書の抽出関数を外側のループに入れれば、構造が同じ文書群を一括処理できる。契約書アーカイブを例にする。アーカイブシステムが一批の技術サービス契約書を受領し、各件の支払計画を取り出して 1 枚の総括表に集める必要があるとする。3 件のサンプル契約書を走査し、それぞれ 1 つ目の表(支払計画)のデータ行を取り出して 1 つの csv に結合し、同時に支払金額を累計する。

FILES = [
    "技術サービス契約書_関東データ.docx",
    "技術サービス契約書_関西運用.docx",
    "技術サービス契約書_九州集成.docx",
]

summaryRows = []
totalAmount = 0
for name in FILES:
    tables = extractTables(os.path.join(ART_DIR, name))
    payTable = tables[0]
    for row in payTable[1:]:
        stage, ratio, amount = row[0], row[1], row[2]
        summaryRows.append([name, stage, ratio, amount])
        totalAmount += int(amount.replace(",", ""))

実行結果は次の通り。

技術サービス契約書_関東データ.docx: 2 表、支払段階 3 条
技術サービス契約書_関西運用.docx: 2 表、支払段階 3 条
技術サービス契約書_九州集成.docx: 2 表、支払段階 4 条
----------------------------------------
契約数: 3 / 表総数: 6 / 支払記録数: 10 / 支払金額合計: 3,680,000 円

集計後の 集計_支払計画.csv は支払記録 10 件で、3 契約書の支払段階が文書順に同一の表に並ぶ。この節は前の 4 種類の抽出を 1 本の流れにまとめる。単一文書の抽出関数を再利用し、外側にファイルのループを重ね、同種の表を 1 枚の総括表に集める。報告書、見積書、プロジェクト文書など構造が同じ文書群に切り替える場合は、ファイルリストと対象表の索引を変えるだけでよい。

6. 境界と適用場面

以上の手法は契約書に限らず、報告書、見積書、プロジェクト文書、アーカイブ資料など本文と表を含む Word 文書に適用できる。文書が内蔵の見出しスタイルと命名しおりを使っていれば、4 種類の抽出はそのまま流用できる。

スタイル名の照合は大文字小文字を区別し、Heading1 と heading1 は等しくない。文書の出所が異なるとスタイル命名が一致しないことがあり(Title や Subtitle を使うテンプレートもある)。まず全量の StyleName を印刷してから絞り込み値を決める方が確実である。

表の抽出で得られるのはセルの平文で、結合セルは Spire の格納構造に従って展開される。横方向の結合セルは row.Cells に 1 回だけ現れ、縦方向の結合セルは後続行の対応位置に空の Cell オブジェクトを占める。結合の意味を復元する必要がある場合は cell.CellFormat.VerticalMerge と cell.GridSpan で判定する。

しおりの抽出は文書中に同名のしおりが実在することに依存する。しおりが他人の手で追加され、命名が統一されていない場合は、まず document.Bookmarks で全しおり名を走査して確認する。

この抽出は Spire.Doc 以外に依存せず、本文はテキスト検索庫、表は csv で集計、見出しは目次索引、しおりは主要区間の取り出し、4 種類の産物を同一の文書走査から分流して出力できる。文書アーカイブ、知識庫のデータ準備、RAG の前処理の工程に組み込みやすい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?