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 で PDF 文書ファイルを分割・再構成する

0
Posted at

四半期ごとに、総務部は受領済みの契約書と請求書を「到着順」にひとつの PDF へ綴じて保管する。1 頁目には書類目録を置き、2 頁目以降は目録に登録した頁の順で本文が続く。到着順は制御できないため、請求書が契約書より先に綴じられたり、同じ契約書の 2 頁が別の文書で分断されたりする。

この形は保管する分には足りるが、参照する場面では不便になる。ある契約書の一式を取り出そうとすると、まず目録頁で契約番号を探し、頁の範囲を控え、該当頁まで送る。対応する請求書は数百頁離れた場所にあるため、また目録頁へ戻って関連番号を確認する。さらに厄介なのが欠落で、請求書がまだ届いていない契約書は本文頁だけを見ても異常が分からず、目録の関連番号を 1 件ずつ突き合わせるしかない。

本記事では Free Spire.PDF for Python を使い、この作業を一本の処理にまとめる。出発点は「頁を数えず、目録を読む」ことにある。1 頁目の書類目録を構造化データとして読み取り、その記録に従って頁の範囲を元ファイルから取り出し、「契約書が先、請求書が後」の順に並べ替えて 1 件ごとのファイルに仕立てる。最終的な成果物は 3 種類——契約書単位のファイル、四半期の合冊ファイル、そして頁数の照合ができる処理レポートである。

pip install spire.pdf.free

例として用いる 文書ファイル_2026Q3.pdf は全 8 頁で、契約書 3 件と請求書 2 件が混在し、うち 1 件は請求書が未着である。サンプル文書と分割結果はそのまま開いて本文の各手順と照合できる。


分割の前に:頁を数えず、目録を読む

処理の入口は書類目録である。目録頁の 1 行が 1 件の登録で、並びは次の形に固定されている。

件番号 | 文書番号 | 文書種別 | 頁 | 関連番号

サンプルの目録頁には 5 件の登録がある。1 行を 5 つの欄に分解すると次のようになる。

件番号 | 文書番号 | 文書種別 | 頁 | 関連番号
1 | KE-2026-0142 | 業務委託契約書 | 2-3 | -
2 | INV-2026-08851 | 適格請求書 | 4 | KE-2026-0142
3 | INV-2026-08907 | 適格請求書 | 5 | KE-2026-0157
4 | KE-2026-0157 | 保守運用契約書 | 6-7 | -
5 | KE-2026-0173 | 物品売買契約書 | 8 | -

目録を読む利点は 2 つある。ひとつは頁の範囲が手に入ること。「何頁目まで」を数えて推測する必要がなくなる。もうひとつは関連番号である。請求書の行にあるこの欄は、その請求書が属する契約書の文書番号を指す。契約書 1 件について関連番号が自分を指す請求書の行が見つからなければ、それは未着の契約書である。機械的に判定できる欠落チェックの唯一の根拠がこの欄になる。

解析は 1 頁分のテキストと 1 本の正規表現で足りる。

# -*- coding: utf-8 -*-
import os
import re
from spire.pdf import (PdfDocument, PdfTextExtractor, PdfTextExtractOptions,
                       PdfMargins, PointF, FileFormat, PdfCompressionLevel,
                       PdfImageHelper)

ARCHIVE = "文書ファイル_2026Q3.pdf"
OUTDIR = "output"
os.makedirs(OUTDIR, exist_ok=True)
ROW = re.compile(r"^\s*(\d+)\s+((?:KE|INV)-\S+)\s+(\S+)\s+(\d+(?:-\d+)?)\s+(\S+)\s*$")
CONTRACT = "契約"


def read_catalog_text(path):
    """目録頁は 1 頁目に固定されている。テキストを取り出すのはこの 1 頁だけ。"""
    doc = PdfDocument()
    doc.LoadFromFile(path)
    text = PdfTextExtractor(doc.Pages[0]).ExtractText(PdfTextExtractOptions())
    total = doc.Pages.Count
    doc.Close()
    return text, total


def parse_catalog(text):
    items = []
    for raw in text.splitlines():
        m = ROW.match(raw)
        if not m:
            continue
        a, _, b = m.group(4).partition("-")      # 頁 "2-3" を開始頁と終了頁に分ける
        items.append({
            "no": m.group(2),                     # 文書番号
            "type": m.group(3),                   # 文書種別
            "pages": list(range(int(a), int(b or a) + 1)),
            "parent": None if m.group(5) == "-" else m.group(5),   # 関連番号
        })
    return items

目録行の区切りは空白で、頁は 2-3 または単頁の 4 と書かれる。partition("-") で分けたあと終了頁が無ければ開始頁に戻すことで、単頁と複数頁を同じ経路で扱える。parent は目録の「関連番号」をそのまま保持する欄で、欠落の判定も帰属の判定もこの 1 項目だけを見る。

この構造化データを用意した時点で、頁の範囲・種別・帰属関係はすべてメモリ上にある。以降の手順で PDF を読み直す必要はない。

成果物 1:契約書 1 件につき 1 ファイル

最初の成果物は契約書単位のファイルである。契約書の本文が先にあり、その請求書が直後に続く——ファイル単体で一式が完結した状態を目指す。

頁の取り出し方は「不要な頁を削除する」のではなく「必要な頁を新しい文書へ複写する」方法を取る。各頁で CreateTemplate() を呼んで頁テンプレートを得て、Draw() で新しい頁の原点に描画する。新しい頁のサイズは元頁の Size をそのまま使い、余白を 0 にすることで内容が拡大縮小されない。

def collect(src, phys_pages, phys_to_target, out_path):
    """物理頁の並びで頁を取り出し、目標の頁順に並べ替えて保存する。"""
    doc = PdfDocument()
    doc.LoadFromFile(src)
    out = PdfDocument()
    for n in phys_pages:
        page = out.Pages.Add(doc.Pages[n - 1].Size, PdfMargins(0.0))
        doc.Pages[n - 1].CreateTemplate().Draw(page, PointF(0.0, 0.0))
    out.Pages.ReArrange([phys_pages.index(n) for n in phys_to_target])
    out.CompressionLevel = PdfCompressionLevel.Best
    out.FileInfo.IncrementalUpdate = False
    helper = PdfImageHelper()
    for i in range(out.Pages.Count):
        for info in helper.GetImagesInfo(out.Pages[i]):
            info.TryCompressImage()
    out.SaveToFile(out_path, FileFormat.PDF)
    out.Close()
    doc.Close()

ReArrange が受け取るのは新しい順序である。リストの i 番目は「新しい文書の i 頁目が元の何番目を使うか」を意味するので、ここでは phys_pages.index(n) で目標の頁番号を頁リスト内の添字に変換している。並べ替えは頁の並びだけを変え、頁の中身には触れない。

本体のループは各契約書とその請求書をひとつの頁リストにまとめ、同時に欠落も判定する。

def page_count(path):
    d = PdfDocument()
    d.LoadFromFile(path)
    n = d.Pages.Count
    d.Close()
    return n


text, total_pages = read_catalog_text(ARCHIVE)
catalog = parse_catalog(text)
print("文書ファイル:%s(全 %d 頁)" % (os.path.basename(ARCHIVE), total_pages))
print("書類目録から %d 件を読み取り:" % len(catalog))
print("件番号 | 文書番号 | 文書種別 | 頁 | 関連番号")
for idx, it in enumerate(catalog, 1):
    seg = str(it["pages"][0]) if len(it["pages"]) == 1 else "%d-%d" % (it["pages"][0], it["pages"][-1])
    print("%d | %s | %s | %s | %s" % (idx, it["no"], it["type"], seg, it["parent"] or "-"))

contracts = [i for i in catalog if i["parent"] is None and CONTRACT in i["type"]]
print("\n契約書 %d 件を 1 件ずつにまとめます:\n" % len(contracts))

made, missing = [], []
for c in contracts:
    invoices = [i for i in catalog if i["parent"] == c["no"]]
    related = sorted([c] + invoices, key=lambda x: x["pages"][0])
    phys = [p for i in related for p in i["pages"]]          # 元ファイルでの物理頁順
    target = list(c["pages"]) + [p for i in related if i is not c for p in i["pages"]]
    out = os.path.join(OUTDIR, "契約ファイル_%s.pdf" % c["no"])
    collect(ARCHIVE, phys, target, out)
    made.append(out)
    if invoices:
        status, note = "完備", "契約 %d 頁 + 請求書 %d 頁" % (len(c["pages"]), len(invoices))
    else:
        status, note = "欠落", "契約 %d 頁、請求書が未着" % len(c["pages"])
        missing.append(c["no"])
    order = "契約書を先頭に並べ替え" if phys != target else "物理頁順のまま"
    print("  %-15s %s  %s(%s)→ %s" % (c["no"], status, note, order, os.path.basename(out)))
    print("      物理頁 %s → 目標頁 %s、出力 %d 頁" % (phys, target, page_count(out)))

phys と target の違いがこの処理の要点になる。phys はそれらの頁が元ファイルで実際に並んでいる順序で、collect に渡す取り出し順になる。target は保管ルールが求める順序——契約書の頁が先、請求書の頁が後——である。サンプルでは KE-2026-0157 の請求書(物理 5 頁目)が契約書(物理 6-7 頁目)より先に到着しているため両者は一致せず、生成時に並べ替えが走る。KE-2026-0142 は到着順がたまたまルールどおりで、両者が一致するため並べ替えは発生しない。status と missing は「この契約書を指す関連番号を持つ請求書の行があるか」だけで決まり、処理全体で唯一の分岐がここにある。

契約ファイル KE-2026-0157 の頁順。請求書が契約書の後ろへ移動している

成果物 2:四半期ファイル(合冊)

2 つ目の成果物は、分割結果を再び 1 冊に戻したものである。「目録頁 + 各契約ファイル」を文書番号の昇順に並べる。

結合には AppendPage を使い、保存済みの契約ファイルを 1 件ずつ追加する。

book = os.path.join(OUTDIR, "四半期ファイル_2026Q3.pdf")
merged = PdfDocument()
toc = PdfDocument()
toc.LoadFromFile(ARCHIVE)
pg = merged.Pages.Add(toc.Pages[0].Size, PdfMargins(0.0))
toc.Pages[0].CreateTemplate().Draw(pg, PointF(0.0, 0.0))
parts = [toc]
for out in sorted(made):
    part = PdfDocument()
    part.LoadFromFile(out)
    merged.AppendPage(part)
    parts.append(part)
merged.CompressionLevel = PdfCompressionLevel.Best
merged.FileInfo.IncrementalUpdate = False
merged.SaveToFile(book, FileFormat.PDF)
merged.Close()
for part in parts:
    part.Close()

print("\n四半期ファイル:%s(%d 頁)" % (os.path.basename(book), page_count(book)))
print("請求書が未着の契約:%s" % ("、".join(missing) if missing else "なし"))

結合で見落としやすいのは、AppendPage の直後に元の文書を閉じてはいけない点である。追加が完了する前に part.Close() を呼ぶと頁の内容が先に解放され、保存時に null 参照の例外になる。追加対象の文書オブジェクトを parts に集めておき、merged.SaveToFile() が戻ったあとにまとめて閉じる——サンプルで parts を用意しているのはこのためである。

合冊の頁数は分割結果と厳密に一致しなければならない。8 頁 = 目録 1 頁 + 3 + 3 + 1(3 件の契約ファイルの合計)である。一致しなければ分割か結合のどこかで頁が落ちている。この照合に追加のロジックは不要で、数字を 1 回突き合わせれば足りる。

四半期ファイル。先頭に目録頁が入る

成果物 3:処理レポート

前の 2 つは総務部が保管するための成果物である。3 つ目は「分割という作業そのもの」の記録で、どの契約書が完備していたか、どれに請求書が欠けているか、各ファイルが何頁で先頭に何が書かれているかを残す。

def first_line(path):
    d = PdfDocument()
    d.LoadFromFile(path)
    t = PdfTextExtractor(d.Pages[0]).ExtractText(PdfTextExtractOptions())
    d.Close()
    for line in t.splitlines():
        if line.strip():
            return line.strip()
    return ""


lines = ["文書ファイル分割結果 2026Q3",
         "文書ファイル:%s(%d 頁)" % (os.path.basename(ARCHIVE), total_pages),
         "目録 %d 件、うち契約書 %d 件" % (len(catalog), len(contracts)),
         ""]
for out in made:
    lines.append("%-30s %d 頁  先頭:%s" % (os.path.basename(out), page_count(out), first_line(out)))
lines.append("%-30s %d 頁" % (os.path.basename(book), page_count(book)))
lines.append("")
lines.append("請求書が未着の契約:%s" % ("、".join(missing) if missing else "なし"))
open(os.path.join(OUTDIR, "処理レポート.txt"), "w", encoding="utf-8").write("\n".join(lines))
print("\n処理レポートを出力しました。")

first_line() は目録を読んだときと同じ抽出方法を使い、各ファイルの 1 頁目から最初の非空行だけを取る。「ファイルの先頭が請求書ではなく契約書になっているか」を確認するためのもので、並べ替えが効いたことの直接の証拠になる。

処理を最後まで走らせたときのコンソール出力とレポートの内容は一致する。

文書ファイル:文書ファイル_2026Q3.pdf(全 8 頁)
書類目録から 5 件を読み取り:
件番号 | 文書番号 | 文書種別 | 頁 | 関連番号
1 | KE-2026-0142 | 業務委託契約書 | 2-3 | -
2 | INV-2026-08851 | 適格請求書 | 4 | KE-2026-0142
3 | INV-2026-08907 | 適格請求書 | 5 | KE-2026-0157
4 | KE-2026-0157 | 保守運用契約書 | 6-7 | -
5 | KE-2026-0173 | 物品売買契約書 | 8 | -

契約書 3 件を 1 件ずつにまとめます:

  KE-2026-0142    完備  契約 2 頁 + 請求書 1 頁(物理頁順のまま)→ 契約ファイル_KE-2026-0142.pdf
      物理頁 [2, 3, 4] → 目標頁 [2, 3, 4]、出力 3 頁
  KE-2026-0157    完備  契約 2 頁 + 請求書 1 頁(契約書を先頭に並べ替え)→ 契約ファイル_KE-2026-0157.pdf
      物理頁 [5, 6, 7] → 目標頁 [6, 7, 5]、出力 3 頁
  KE-2026-0173    欠落  契約 1 頁、請求書が未着(物理頁順のまま)→ 契約ファイル_KE-2026-0173.pdf
      物理頁 [8] → 目標頁 [8]、出力 1 頁

四半期ファイル:四半期ファイル_2026Q3.pdf(8 頁)
請求書が未着の契約:KE-2026-0173

処理レポートを出力しました。

分割処理の実行出力

契約書 1 件に請求書が欠けているという事実は、頁の分割だけを行っても見つからない。目録の関連番号が判定に加わって初めて出てくる情報である。未着の一覧をレポートに載せておけば、担当者が受け取るのは分割結果だけでなく「今期の保管はまだ不完全である」という未処理の一覧になる。

PDF の頁操作に関するクラスとメンバー

クラス / メンバー 役割
PdfDocument.LoadFromFile 文書ファイル、または保存済みの契約ファイルを読み込む
PdfDocument.Pages 頁コレクション。Count で頁数、Add(Size, PdfMargins) で空頁を追加
PdfPageBase.CreateTemplate 1 頁の表示内容を再利用可能な頁テンプレートにする。Draw と組で頁の複写になる
PdfPageBase.Size 頁サイズ。複写時に新頁を元頁と同じ大きさに保つ
PdfPageCollection.ReArrange 指定した順序で頁を並べ替える。引数は「新しい順序が使う元の添字」
PdfDocument.AppendPage 別の文書の全頁を現在の文書の末尾へ追加する
PdfDocument.CompressionLevel 圧縮レベル。サンプルは PdfCompressionLevel.Best
PdfDocument.FileInfo.IncrementalUpdate False にすると増分更新ではなく完全な文書として保存される
PdfImageHelper.GetImagesInfo 頁内の画像を列挙する
PdfImageInfo.TryCompressImage 画像ごとに圧縮を試み、出力サイズを抑える
PdfTextExtractor.ExtractText 頁のテキストを抽出する。引数に PdfTextExtractOptions() が必要
PdfMargins / PointF 余白と描画座標

ReArrange と AppendPage はそれぞれ別のサンプルに由来する。本記事ではこれを 1 本の処理に組み込み、前者で「契約ファイル内部の頁順」を、後者で「契約ファイル同士の連結」を担わせている。どちらも頁だけを扱い、頁内のオブジェクトには触れない。

適用範囲と制約

目録は 1〜3 頁目に置く必要がある。 Free 版のテキスト抽出には頁数の上限がある。サンプルを 1 頁ずつ抽出すると 1〜3 頁目はそれぞれ 1017、569、755 文字を返し、4 頁目以降はすべて空文字列になる。8 頁目だけを単独で抽出しても結果は空文字列であり、全頁を逆順(8 頁目から 1 頁目)に抽出しても内容が返るのは 1〜3 頁目だけである。つまり制限は「抽出した総量」ではなく頁の範囲に対してかかる。同じ制限は画像化にも現れ、SaveAsImage(7) は例外を出さずに真っ白な頁を返す。したがって本方式では、機械可読な情報を 1 頁目の目録に集約し、本文頁は画像として運ぶだけにしてテキスト照合の対象にしない。

頁テンプレートの複写が運ぶのは表示層である。 CreateTemplate().Draw() が複写するのは頁の描画内容で、頁単位の注釈・リンク・フォームフィールドは移動しない。署名フィールドやしおりを持つ文書を扱う場合は別途の処理が要る。

欠落の判定は目録の欄に依存する。 関連番号はこの種の文書ファイルの登録ルールとして存在する欄であり、この列が無いファイルでは欠落判定の根拠が失われる。金額や日付による突き合わせに後退するしかない。

頁数が自己検証の錨になる。 合冊の頁数は目録頁と各契約ファイルの頁数の合計に一致するはずで、契約ファイルの頁数は契約書の頁範囲と請求書の頁範囲の長さの合計に一致するはずである。どちらかが合わなければ頁の取りこぼしか重複取り出しが起きている。phys_pages のリストを確認すれば特定できる。

電子帳簿保存法との関係。 電子取引のデータは電子のまま保存することが求められており(2024 年 1 月に宥恕措置が終了)、契約書と請求書を書類単位で整理しておく実務上の必要性は高い。ただし本方式が行うのは既存 PDF の頁の再構成であり、保存要件そのものを満たすかどうかの判定は別途の検討が必要である。

次の四半期の文書ファイルに差し替える

入力に求める条件は 2 つだけである。目録が 1 頁目にあること、登録の書式が安定していること。四半期が変わったら ARCHIVE 定数を差し替え、番号の接頭辞(KE- / INV-)と契約の種別キーワード(契約)を実際の登録ルールに合わせて正規表現を調整すれば、他のロジックは変わらない。

目録頁以外に広げられる方向としては、処理レポートを同時に CSV で書き出して複数四半期の未着状況を集計する、契約ファイルの 1 頁目に文書番号入りの透かしを入れて原本との追跡性を確保する、collect() の頁指定を外部設定から与えて契約書以外の混載ファイル(入札書類と資格証明など)にも対応する、といったものが考えられる。いずれも本記事の中心となる考え方——目録を唯一の根拠とし、各手順の出力を頁数で照合できるようにする——を変えるものではない。

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?