【Python】PyMuPDFでPDFを画像化するバッチを社内配布する方法(AGPLライセンスと運用設計)

概要

今回はPyMuPDFを使ってフォルダ配下のPDFを再帰的に一括PNG化するWindows向けバッチツールを、社内配布する際の設計と注意点について解説していきたいと思います。

「PDFをページ単位でPNGにしたい」というニーズ、業務だと地味に多いですよね(^^

OCR前処理、スライドの画像化、PDFの比較の為の画像化あたりでよく行います。

ただ「動くだけ」のスクリプトと「社内配布して運用できる」バッチでは、設計で意識するポイントが結構違います

さらにPyMuPDFはAGPL-3.0 / Artifex商用のデュアルライセンスというクセ付きなので、配布範囲を誤るとライセンス違反になるリスクもあります(- -;

本記事ではPyMuPDF 1本で作った小さなツールを題材に、ライセンス運用・再帰探索・設定ファイル駆動・ジェネレータ設計・終了コード連携など、社内配布を想定した判断ポイントを一通り解説します。
それではやっていきましょう!

目次

作ったもの

Windows向けCLIバッチとして、以下の仕様で実装しました。

<仕様>
  • config.iniで入力フォルダ・DPI・上書き可否・ファイル名書式を指定
  • 指定フォルダを再帰的に探索してPDFを全部PNG化
  • 各PDFと同じフォルダに <PDF名>_p001.png, p002.png, … を生成
  • 終了コード 0/1/2 で状態を返す(バッチ呼び出し側が分岐可能)

構成は超シンプルで、Pythonファイルは2つだけです。

ディレクトリ構成

1
2
3
4
5
6
7
8
PDF画像変換ツール/
├── README.md
├── LICENSE.AGPL-NOTICE.md
├── requirements.txt
├── config.ini
└── src/
├── main.py
└── pdf_renderer.py

配布前に必ず確認:PyMuPDFのAGPLライセンス

実装の前にライセンス運用の話を先にしておきます
ここを誤ると、作った後に「配布できません」という話になりかねません。

PyMuPDFのライセンス形態

PyMuPDFはAGPL-3.0とArtifex商用のデュアルライセンスです。

<2種類のライセンス>
  • GNU Affero General Public License v3.0(AGPL-3.0)
  • Artifex Software 商用ライセンス(有償、見積もり制)

AGPLは普通のGPLに加えて「ネットワーク越しにサービス提供する場合もソース開示義務」という条項が付く、比較的強めのコピーレフトライセンスです。

社内バッチCLI限定なら問題ない条件

AGPLがソース開示を要求するのは「配布」または「ネットワーク経由での提供」が行われたときなので、以下を満たすなら無料のAGPLのまま運用できます。

<AGPLで運用可能な条件>
  • 社内の特定部署・チーム内でのみ利用する
  • ネットワーク越しに外部ユーザへ機能を提供しない
  • 社外の組織・個人にバイナリ・ソースを配布しない
  • GitHub等での一般公開も行わない

AGPLが発動する「3つの再評価トリガー」

以下のいずれかに該当した瞬間、AGPL条項が発動してライセンス再評価が必要になります。

<再評価が必要なケース>
  • 外部配布(協力会社納品、社外利用者への配布など)
  • SaaS/Web API化(ネットワーク越しに機能提供)
  • 派生物の公開(GitHub等での一般公開)

該当したら対応は2択です。

対応1:派生物を含めてソース全体をAGPL-3.0で公開する
対応2:Artifex社から商用ライセンスを購入する

個人・小規模配布でもArtifex商用は見積もり制で、安くはない金額になる前提で予算確保が必要です。

逃げ道:pypdfium2への乗り換え

AGPLを避けたいならpypdfium2(Apache-2.0 / BSD-3)への乗り換えが最有力候補です。
Chromium組み込みのPDFiumをPythonから叩くラッパーで、商用配布・SaaS化・GitHub公開が自由にできます。

APIはPyMuPDFよりやや煩雑ですが、「PDFをページ単位で画像化するだけ」ならラッパー1枚挟めば数時間で移植可能です。

LICENSE.AGPL-NOTICE.md を同梱しておく

社内利用でも将来の自分のために、以下のファイルを同梱しておくと安全です。

<NOTICEに書いておく内容>
  • PyMuPDFが使用されている旨とライセンス種別
  • 本プロジェクトの運用前提(社内CLI限定など)
  • AGPL再評価が必要になるトリガー条件
  • 対応オプション(AGPL公開 or Artifex商用)

運用方針が変わって「このツールを他社にも渡せないか?」という話が出たときに、再評価ポイントが即座にわかる状態になります。

PyMuPDFラッパの設計(pdf_renderer.py)

ライセンス面がクリアになったら、実装に入ります。
まずPyMuPDFを薄くラップする層を切り出します。

src/pdf_renderer.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"""PyMuPDF ラッパ
"""

from __future__ import annotations

from pathlib import Path
from typing import Iterator

import pymupdf


def iter_pages(pdf_path: Path, dpi: int) -> Iterator[pymupdf.Pixmap]:
"""pdf_path の各ページをレンダリングして Pixmap を順に返す。"""
with pymupdf.open(pdf_path) as doc:
for page in doc:
yield page.get_pixmap(dpi=dpi)

設計ポイント1:with で Document を確実に解放する

PyMuPDFの pymupdf.open() は内部でMuPDFのネイティブリソースを握ります

Python層で pymupdf.open(path) を変数に代入しただけだと、例外でスコープを抜けたときにリソースが残る可能性があります。
必ず with で開いて、スコープを抜けたら自動closeされる形にするのが安全です。

設計ポイント2:Pixmapを yield する理由

関数をジェネレータにしてページごとに yieldしています。

PDFが100ページあっても、100枚のPixmapを同時にメモリに載せないために重要なパターンです。
200DPIで1ページ数MBのPixmapになるので、大量ページPDFを全ロードすると普通にメモリを食い尽くします。

Pixmapは画素データを自前バッファに持つので、元のpageオブジェクトが解放されても有効です。
yieldした後でpixを使い続けても問題ありません。

設定ファイル設計(config.ini)

設定はPython標準の configparser で読めるINI形式にしました。

config.ini

1
2
3
4
5
6
7
8
9
10
11
12
13
[input]
# PDF を再帰的に探索するフォルダの絶対パス
folder = C:\Programming\PDF画像変換ツール\test_pdf

[output]
# 解像度 (DPI)
dpi = 200

# 同名 PNG が既に存在する場合に上書きするか
overwrite = false

# 出力ファイル名の書式
filename_pattern = {stem}_p{page:03d}.png

ini形式だと設定変更が簡単にできるので、個人的にはマストです!!

filename_pattern の {stem}/{page:03d}

出力ファイル名の書式を{stem}_p{page:03d}.pngにしています。

<採用理由>
  • {stem} : PDFの拡張子なしファイル名。Explorerで視覚的にグループ化できる
  • _p : ページ区切り識別子(他の文字列と混同しにくい)
  • {page:03d} : 3桁ゼロ埋め。Explorerのファイル名ソートで正しい順序になる

{page} のままだと 1, 10, 11, 2, 20, 3, ... という文字列ソートになってしまうので、ゼロ埋めはやっておいた方がソートしやすくて便利かと思います(^^

DPI設定の使い分け

<DPIの目安>
  • 150 : ドラフト・メール添付用
  • 200 : 業務文書のサムネ生成(デフォルト推奨)
  • 300 : OCR前処理、印刷品質
  • 400以上 : 細かい図版があるマニュアル等、特殊用途のみ

300DPIあたりから処理時間とファイルサイズが急増するので、OCR前処理の必要がなければ200固定で十分です。

再帰探索とシンボリックリンク対策(main.py)

フォルダ配下を再帰的に探索してPDFだけ拾う関数です。

src/main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
def iter_pdfs(folder: Path) -> Iterable[Path]:
"""folder 配下から PDF ファイルを再帰的に列挙する。"""
seen: set[Path] = set()
for path in folder.rglob("*"):
if not path.is_file():
continue
if path.suffix.lower() != ".pdf":
continue
resolved = path.resolve()
if resolved in seen:
continue
seen.add(resolved)
yield path

ポイント:resolve() + set で重複排除

ショートカット(.lnk)やシンボリックリンクが絡むと、同じ実体PDFが複数回拾われる可能性があります。

そこでresolve() で正規化したパスを set に入れて重複排除しています。

拡張子判定は suffix.lower() == ".pdf" にしているので、.PDF のように大文字拡張子のファイルもちゃんと拾えます。

1ファイル変換とエラー遮断

1つのPDFを処理する関数です。

src/main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def convert_one(pdf_path: Path, cfg: Config) -> tuple[int, int, int]:
"""1 つの PDF をページごとに PNG へ書き出す。"""
success = skipped = failed = 0
try:
for page_index, pix in enumerate(iter_pages(pdf_path, cfg.dpi), start=1):
out_name = cfg.filename_pattern.format(stem=pdf_path.stem, page=page_index)
out_path = pdf_path.parent / out_name

if out_path.exists() and not cfg.overwrite:
log.info("skipped (exists): %s", out_path)
skipped += 1
continue

try:
pix.save(str(out_path))
log.info("wrote: %s", out_path)
success += 1
except Exception:
log.exception("failed to write %s", out_path)
failed += 1
except Exception:
log.exception("failed to process %s", pdf_path)
failed += 1
return success, skipped, failed

ポイント:try/except を二重にする

1ページの失敗で全体が止まらないよう、try/exceptを「PDF全体」と「ページ単位」で二重に張るのが重要です。

<二重try/exceptの意図>
  • 外側のtry : PDFを開く段階・構造が壊れている場合を捕捉
  • 内側のtry : 特定ページだけレンダリング失敗した場合を捕捉

これで「1ファイル壊れていても他のPDFは全部処理する」「1ページだけ異常でも他ページは出力する」バッチになります。

途中で処理を止めたくないので、こういった制御にしてます。

ポイント:例外は logging.exception で吐く

printではなく log.exception() を使っているのは、スタックトレース込みでERRORログに流れるからです。

業務で使うバッチは、エラーが出たときに「何時何分にどのファイルで落ちたか」を後から追跡できることが必須なので、logging経由で統一します。

設定も見やすいし、ファイルに出力するとしてもすぐ変更可能なのでお勧めです!

終了コード設計(0/1/2)

main関数の末尾で終了コードを返しています。

src/main.py

1
2
3
4
5
6
7
8
9
10
def main() -> int:
# ... 省略 ...

if total_f == 0:
return 0
return 2


if __name__ == "__main__":
sys.exit(main())

終了コードの意味論

<終了コード>
  • 0 : 全PDF正常処理(失敗0件)
  • 1 : 設定不備・入力フォルダ不在(処理開始前に失敗)
  • 2 : 処理は進んだが1件以上変換失敗(要ログ確認)

batファイル側の分岐例

Windowsのbatから呼び出す場合、%ERRORLEVEL%で分岐できます。

call.bat

1
2
3
4
5
6
7
8
python src\main.py
if %ERRORLEVEL% EQU 0 (
echo 全PDF正常処理
) else if %ERRORLEVEL% EQU 1 (
echo 設定不備 - config.ini を確認
) else if %ERRORLEVEL% EQU 2 (
echo 一部失敗あり - ログ確認
)

タスクスケジューラに登録したときも、失敗検知が楽になります。

動作確認の目安(手動テスト)

小規模ツールで自動テストを書かない代わりに、5種類の代表的PDFでの手動確認項目をREADMEに記載しておきました。

<手動テスト観点>
  • 単ページPDF(最小ケース)
  • 複数ページPDF(10ページ程度)
  • 日本語ファイル名のPDF(エンコーディング確認)
  • スキャン画像のみのPDF(ベクタなし)
  • テキスト主体のPDF(フォント描画の回帰確認)

加えて、overwrite=false での再実行でskipログが出ること壊れたPDFを混ぜて実行しても他のPDFは処理されることも確認項目に入れています。

社内配布前のチェックリスト

最後に、配布前に必ず確認したいポイントをまとめました。

配布前チェック

1
2
3
4
5
6
7
□ LICENSE.AGPL-NOTICE.md を同梱した
□ 配布先が「社内・同一組織内」に収まる
□ SaaS/Web API化の予定がない
□ 一般公開(GitHub等)の予定がない
□ README に前提環境・config.iniの書き方を明記
□ 手動テスト5種で動作確認済み
□ 終了コード 0/1/2 の意味を呼び出し側に説明済み

1つでも該当しなかった項目があれば、そのまま配ると後でトラブルになる可能性が高いので、見直してから配布しましょう。

また確実に上記だけで足りている保証はできないので、各自自分で調べるようにしてください(^^

締め

今回はPyMuPDFでPDFを一括PNG化するツールの社内配布向け設計を紹介しました。

ポイントをおさらいすると以下のとおりです。

<まとめ>
  • PyMuPDFはAGPL / Artifex商用のデュアルライセンス。配布範囲は要確認
  • 社内CLI限定ならAGPLのまま運用可能、配布時は注意
  • 終了コード 0/1/2 でbat呼び出し側と連携
  • AGPLを避けたい場合はpypdfium2に乗り換え可能

ツールを作るときに運用側の視点とライセンス面を少し書いておくと、感謝されることが多いので、業務用のPythonツールを書く人は意識するとよいかもです(^^b

以上となります。
ライセンスは複雑で難しいですよね。。。頑張って理解するようにしましょう!
それではお疲れさまでした。