PythonでPDF編集&画像出力

将来的にプロジェクトでPDFのテストデータを作成する必要が見込まれたため、その準備として対応方法をまとめました。

目次

目的

  • AWS bedrockに読み込ませるためのテストデータを作成する
  • テストデータは源泉徴収票をPDFまたは画像(JPEG)を対象とする
  • 画像(JPEG)のファイルサイズを調整し、境界値テストを実施できるようにする

使用環境

  • OS: Windows 10 / 11
  • Python: 3.11(conda仮想環境)
  • PDF描画ライブラリ: PyMuPDF(fitz)

環境構築手順

# 仮想環境作成
conda create -n pdfenv python=3.11

# 有効化
conda activate pdfenv

# pipでPyMuPDFをインストール(condaには無い)
pip install pymupdf

# 動作確認
python -c "import fitz; print(fitz.__doc__)"

出力例:
```
PyMuPDF 1.26.4: Python bindings for the MuPDF 1.26.7 library (rebased implementation). 
Python 3.11 running on win32 (64-bit).
```

入力ファイル(源泉徴収票)

国税庁:F1-1 給与所得の源泉徴収票(同合計表)
https://www.nta.go.jp/taxes/tetsuzuki/shinsei/annai/hotei/pdf/r07/02.pdf

1ページ目には入力フォームが設定されたフォーマットが含まれています。

出力ファイル

  • 入力フォームを削除し、値が固定されたPDF(左側半分)を出力
    →※値の編集はできない

  • PDFを画像化したJPEGファイルを生成

処理フロー

  1. 入力ファイル内の各フォームのキー値の抽出
  2. 各キーと値をマッピングしたJSONデータを用意
  3. 各フォームにJSONデータの値を設定
  4. PDFをフラット化(フォーム削除処理)して出力
  5. PDFの内容を画像として出力

コード

1.入力ファイル内の各フォームのキー値の抽出

import fitz  # PyMuPDF
import json

pdf_path = "r07_02.pdf"
output_json = "input_data_template.json"

doc = fitz.open(pdf_path)

# すべてのページからフィールド名を収集
field_names = set()

for page_index in range(len(doc)):
    page = doc.load_page(page_index)
    widgets = page.widgets()
    if not widgets:
        continue
    for w in widgets:
        if w.field_name:
            field_names.add(w.field_name)

# テンプレート構造を生成
template_data = [{name: "" for name in sorted(field_names)}]

# JSONファイルとして出力
with open(output_json, "w", encoding="utf-8") as f:
    json.dump(template_data, f, ensure_ascii=False, indent=2)

2.各キーと値をマッピングしたJSONデータを用意

[
  {
      "氏名": "テスト 太郎",
      "住 所 又 は 居 所": "東京都千代田区霞が関1−1−1",
      "支払(内)": "5000000",
      "源泉徴収税額": "150000",
      "社会保険料等の金額": "700000",
      "生命保険料の控除額": "80000",
      "地震保険料の控除額": "50000",
      "配偶者(特別)控除の額": "380000",
      "基礎控除の額": "480000",
      "支払者住所(居所) 又は所在地": "東京都中央区",
      "支払者氏名又は名称": "株式会社 テスト開発"
  }
]

3.値の設定〜PDF出力

# -----------------------------
# 設定
# -----------------------------
INPUT_PDF = "r07_02_filled.pdf"
OUTPUT_DIR = "output_final"
TARGET_BYTES = 4.3 * 1024 * 1024  # JPEGターゲットサイズ 4.3MB
os.makedirs(OUTPUT_DIR, exist_ok=True)

dpi_for_png = 300  # PNGは固定DPI

ts = datetime.now().strftime("%Y%m%d_%H%M%S")
BAKED_PDF = os.path.join(OUTPUT_DIR, f"filled_baked_{ts}.pdf")
LEFT_PDF = os.path.join(OUTPUT_DIR, f"filled_left_{ts}.pdf")
LEFT_JPEG = os.path.join(OUTPUT_DIR, f"filled_left_{ts}.jpg")

def main():
    if not os.path.exists(INPUT_PDF):
        print(f"入力PDFが見つかりません: {INPUT_PDF}")
        return

    doc = fitz.open(INPUT_PDF)
    print(f"Opened: {INPUT_PDF}  pages={len(doc)}")

    # (1) フォームデバッグ
    # debug_print_widgets(doc)

    # (2) フォームを焼き込み
    print("Calling doc.bake() ...")
    doc.bake()
    print("doc.bake() done.")

    # (3) 焼き込み済みPDFを保存
    doc.save(BAKED_PDF)
    print(f"Baked PDF saved: {BAKED_PDF}")

    # (4) 左半分をPDFとして切り出し
    page0 = doc[0]
    rect = page0.rect
    clip_rect = fitz.Rect(0, 0, rect.width / 2, rect.height)

    new_doc = fitz.open()
    new_page = new_doc.new_page(width=clip_rect.width, height=clip_rect.height)
    new_page.show_pdf_page(new_page.rect, doc, 0, clip=clip_rect)
    new_doc.save(LEFT_PDF)
    new_doc.close()
    print(f"Left-half PDF saved: {LEFT_PDF}")

    # (5) JPEG出力(ターゲットサイズに寄せる)
    save_jpeg_with_target_size(page0, LEFT_JPEG, TARGET_BYTES, clip_rect)

    doc.close()
    print("Done.")

4.画像出力

# DPI を調整してターゲットファイルサイズに寄せるJPEG出力
def save_jpeg_with_target_size(page, output_path, target_bytes, clip_rect):
    min_dpi = 72
    max_dpi = 3000
    best_dpi = min_dpi
    best_size = 0
    best_pix = None

    for trial in range(15):
        dpi = (min_dpi + max_dpi) / 2
        matrix = fitz.Matrix(dpi / 72.0, dpi / 72.0)
        pix = page.get_pixmap(matrix=matrix, clip=clip_rect, alpha=False)

        # Pixmapのバイトサイズで比較
        size = len(pix.tobytes("jpeg"))

        print(f"[trial {trial}] dpi={dpi:.1f}, size={size/1024:.2f}KB")

        if size > target_bytes:
            max_dpi = dpi
        else:
            min_dpi = dpi
            best_dpi = dpi
            best_size = size
            best_pix = pix

        if abs(size - target_bytes)/target_bytes < 0.01:
            best_pix = pix
            best_dpi = dpi
            best_size = size
            break

    if best_pix is None:
        best_pix = page.get_pixmap(matrix=fitz.Matrix(300/72,300/72), clip=clip_rect, alpha=False)
        best_dpi = 300
        best_size = len(best_pix.tobytes("jpeg"))

    # 最終的にJPEGとして保存
    best_pix.save(output_path, output="jpeg")
    print(f"→ 出力JPEG: {output_path} ({best_size/1024/1024:.2f}MB, dpi={best_dpi:.1f})")
    return best_pix, best_dpi

問題点など

  • pypdf ライブラリが使用できなかった

プロジェクト内では当初pypdfライブラリを使用していましたが、以下の問題により利用を見送りました。

PDFには値が反映されず、画像出力時のみ値が反映される

原因

PDFにはフィールド値(実データ)と外観ストリーム(表示内容)が存在しますが、プログラムで値を設定しても外観ストリームが更新されない場合があり、そのままフラット化すると値が表示されないことが原因である
と考えられます。

PDFを画像出力した場合、値は反映されるものの、一部で文字化けが発生

「支払いを受ける者」欄は正常に表示される一方、「支払者」欄では文字化けが発生していました。

原因(推測)

フォームに設定されているフォントがHelvetica系のフォントであったため、本来は代替フォントで表示されるはずが、日本語対応していない外観ストリームの問題でうまく描画されなかった可能性が高いと考えています。

現時点ではPyMuPDFを採用していますが、原因や解決方法が分かれば再検討の余地があります。

残課題

  • PDFのファイルサイズを任意に調整できるようにしたい
    → /Metadataにダミーデータを追加すればサイズを増やすことができると考えています。

  • PNGなど別画像の形式に対応したい
    → JPEGなどの非可逆圧縮形式とは異なり、可逆圧縮形式のため別アプローチが必要になります。
役に立ったらシェアしていただけると嬉しいです
  • URLをコピーしました!
  • URLをコピーしました!
目次