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?

PySimpleGUIとPyMuPDFで作った捺印できるPDFページ分割ツール

0
Last updated at Posted at 2026-05-28

目次

  1. はじめに
  2. 仕様・使用した技術・環境
  3. 成果物
  4. ソースコード
  5. 終わりに
  6. 参照資料

はじめに

作成したもの : 捺印できるPDFページ分割ツール

作成した理由 :
独学でPythonを学んでいて、作りたいものが見つからず悩んでいるときに友人からなにか業務効率化してほしいものはないかアイデアを募集したところ、送られてくる注文書に捺印できるツールがあれば嬉しいとアイデアをいただき実装に至りました。

作成時期 :
2025/3頃から一週間

今回はどんなものを作ったのか紹介するための記事なので、ライブラリの使い方等は省略させていただきます。
なお、参照資料の章にて使用したライブラリ類のドキュメントURL添付しておきますので、詳しいことを知りたい方はそちらをご参照ください。


仕様・使用した技術・環境

仕様

いかんせん一年前のためうろ覚えなことをご了承ください。

  • 概要 : 4ページのPDFに捺印をして分割した4つのファイルとして出力をする
  • 入力: PDFファイル一つと捺印する名前
  • 出力: 分割したPDF4ファイル
  • PDFのプレビュー、ファイル名が注文書一式からはじまるかどうか、入力ファイル出力フォルダが選択されているかどうか

使用した技術・環境

  • 言語: Python 3.12
  • 主要ライブラリ:
    • PySimpleGUI:GUI画面の構築、イベント管理
    • PyMuPDF (PyWinContext含むfitz):PDFのレンダリング、ハンコ画像の埋め込み、PDF分割
    • Pillow (PIL):キャンバスからの画像切り出し、プレビュー合成
    • os, shutil:一時フォルダ(temp/)の自動管理
  • 実行環境 :
    • OS : windows
    • エディタ : VS code

成果物

作成したアプリの画面構成は以下の通りです。

  • 左側(I/Oフレーム): ファイル選択、ハンコの動的生成、各種スライダー、保存ボタン
  • 右側(previewフレーム): PDFのページプレビュー、クリックによる捺印操作

アプリを起動すると、左側に操作パネル、右側にプレビューエリアが表示されます。
スクリーンショット 2026-05-27 205137.png


入力ファイルを選択すると、プレビュー画面に表示されます。(選べるファイルはファイル名が注文書一式から始まるファイルのみです。)
入力ファイルと保存先フォルダはそのパスが表示されます。

今回使用してる注文書はGeminiに作ってもらった架空の注文書です。

スクリーンショット 2026-05-28 224116.png


はんこの生成は文字を入力すると、はんこプレビューのデフォルト値である「名前」から「入力した文字」に置き換わります。
はんこの半径と文字の大きさは各スライダーで変更可能です。
最後に「ハンコ生成」ボタンを押すことで、はんこが一時フォルダに保存されます。(プログラム終了時に一時フォルダは削除されます)


ウィンドウサイズが小さすぎるとプレビューに表示されているPDFと捺印位置との座標にずれが生じてしまいます。
それを防ぐために小さすぎる場合は、警告文が赤文字になり、保存ボタンが押せないようになっています。
スクリーンショット 2026-05-28 231634.png

ウィンドウサイズを十分に大きくすると、以下の画像のように警告文が緑色になります。
この状態で捺印したい場所にクリックすると、実際のPDFの座標と同じ位置に捺印が施されます。
後今更ですが、PDFファイルはプレビュー下部のボタンかマウススクロールでページをめくることが出来ます。
スクリーンショット 2026-05-28 231123.png

これで保存ボタンを押すと、保存先フォルダ内に捺印された1ページと他三ページのPDFファイルが四つに分割され出力されます。


こだわった技術的アピールポイント(工夫した点)

1. TkinterのCanvasを応用した「ハンコのリアルタイムプレビュー&画像化」

PySimpleGUIの裏で動いているTkinterの tk_canvas を直接操作し、ユーザーが入力した文字やスライダーで調整したサイズをリアルタイムにプレビュー描画させています。

さらに、そのキャンバスの絶対座標を winfo_rootx() と winfo_rooty() で正確に取得し、Pillow の ImageGrab.grab().crop() を使ってその場限りのハンコPNG画像を動的に切り出して生成しています。

def create_stamp_png(canvas, canvas_widget, circle_radius):
    # 画面上のcanvasの絶対位置を取得
    abs_x = canvas_widget.winfo_rootx()
    abs_y = canvas_widget.winfo_rooty()

    width = canvas.winfo_width()
    height = canvas.winfo_height()

    out_circle = 50 - int(circle_radius)
    left_x = abs_x + out_circle
    left_y = abs_y + out_circle
    right_x = left_x + width - 2*out_circle + 1
    right_y = left_y + height - 2*out_circle + 1

    # 狙った座標をスクリーンショットとして切り出してPNG保存
    image = ImageGrab.grab().crop((left_x, left_y, right_x, right_y))
    image.save('temp/test.png')

固定の画像ファイルを用意するのではなく、プログラム内で文字を縦書き(\n 挿入)に自動変換し、サイズに合わせて枠円と文字を即座に画像化するハックを施しました。

2. プレビュー画像クリックによる「直感的な捺印位置の決定」

PDFを1ページずつ画像に変換して画面上に配置し、ユーザーが画像をクリックした際のマウスポインタの相対座標を割り出しています。

if event == 'IMAGE' and flag:
    if stamp_flag:
        widget = window["IMAGE"].Widget
        # 画像内の相対クリック座標を計算
        x = widget.winfo_pointerx() - widget.winfo_rootx()
        y = widget.winfo_pointery() - widget.winfo_rooty()
        radius = int(values['-slider-'])
        # PDFの該当座標にハンコ画像をインサート
        viewer_path = stamp_in_viewer(x, y, path, page_count, radius)

取得した (x, y) 座標を PyMuPDF の page.insert_image(rect, stream=img) に引き渡すことで、紙にハンコを捺すような直感的な操作感を実現しました。

3. バグや操作ミスを防ぐ安全設計(UI/UXの工夫)

表示されているウィンドウサイズがPDFの実解像度より小さいと、画面上のクリック位置とPDF内部の座標にズレ(バグ)が生じてしまいます。
これに対し、メインループ内で常にウィンドウサイズとPDFサイズをバリデーションする機能を実装しました。

# ウィンドウが十分に拡がっているか常時チェック
if flag and frame_2_width > pdf_width and frame_2_height > pdf_height:
    window['-warning-'].update(text_color='green')
    window['-save-'].update(disabled=False) # 安全なら保存を許可
else:
    window['-warning-'].update(text_color='red')
    window['-save-'].update(disabled=True)  # ズレる危険がある時はボタンをロック

安全な時は警告文がグリーンになり、危険な時は赤文字で警告を発して「分割して保存」ボタンを非活性(disabled)にする仕様にすることで、ユーザーの操作ミスによる不良ファイルの生成を未然に防いでいます。


開発中に苦労したエラーとその解決策

開発中、環境構築とライブラリの競合で以下のエラーに直面しました。

ModuleNotFoundError: No module named 'frontend'

原因と対策

PDFを扱うための本物のライブラリは PyMuPDF であり、コード内での呼び出し名が import fitz となります。しかし、誤って pip install fitz というコマンドを実行してしまったため、名前が同じだけの全く無関係な古いパッケージ(fitz 0.0.1.dev2)がインストールされ、内部で衝突を起こしていたことが原因でした。

以下の手順で、一度競合したパッケージを完全に削除し、正しいパッケージのみを入れ直すことで解決しました。

# 誤って入った古いfitzを削除
pip uninstall fitz
# 壊れた可能性のあるPyMuPDFも一度削除
pip uninstall PyMuPDF
# 正しい本物のパッケージだけをインストール
pip install PyMuPDF

Pythonにおける「パッケージ名」と「import 時に指定する名前」が異なるライブラリを扱う際の、良い学びになりました。


ソースコード

ソースコードの全容は以下の通りです。
併せてリポジトリのURLも添付しておきます。

ソースコードを表示する(main.py)
main.py
import PySimpleGUI as sg
import fitz
from PIL import Image, ImageGrab, ImageDraw
import os
import shutil

theme = sg.theme('DarkBlack1')

column_layout = [
                 [sg.Input(size=(10,2), key='-name-'), sg.Button('ハンコ生成', key='-generate-', button_color=('white','green'), enable_events=True,)],
                 [sg.Slider(range=(5,25), default_value=15, orientation="h", enable_events=True, key='-text_size-')]
                 ]

frame_1= [
          [sg.Text('入力ファイル: ')],
          [sg.Input(size=(45,1), key='-input-', enable_events=True), sg.FileBrowse(file_types=(('PDFファイル','*.pdf'),))],
          #出力ファイル
          [sg.Text('出力先フォルダ:', pad=((0,0),(20,5)))],
          [sg.Input(size=(45,1), key='-output-'), sg.FolderBrowse()],
          #ハンコ
          [sg.Text('捺印する名前を入力してください(最大で四文字まで):', pad=((0,0),(20,5)))],
          [sg.Column(column_layout),
           sg.VerticalSeparator(pad=((20,5),(5,5))), sg.Canvas(background_color='white', size=(100,100), key='-sample-'),
           sg.Slider(range=(10,50),enable_events=True, key='-slider-',default_value=25)],
           #保存
          [sg.Button('分割して保存',key='-save-', button_color=('white','red'), disabled=True)],
          [sg.Text('・PDF全体が表示されるようにウィンドウを拡げてください。\n捺印する際の座標がずれる可能性があります', text_color='red', key='-warning-')],
          [sg.Text('ページをスクロールすると捺印が取り消されるので\nもう一度捺印してください。', text_color='yellow')]
          ]

frame_2 = [
           [sg.Image(data=None, key='IMAGE', enable_events=True, )],
           [sg.Button('前へ', disabled=True), sg.Button('次へ', disabled=True), sg.Text('0 / 0',key='-page-',font=('Helvetica',15))],
           ]

#PDFファイルを画像に変換
def pdf_to_image(pdf_file, page_count):
    pdf = fitz.open(pdf_file)
    pdf_page = pdf[page_count]
    pix = pdf_page.get_pixmap()
    # print(pix)
    data = pix.tobytes()
    pdf.close()
    return data

#ページ数を取得
def get_page_PDF(pdf_file):
    pdf = fitz.open(pdf_file)
    page_num = len(pdf)
    pdf.close()
    return page_num

#pdfサイズを取得
def get_size_pdf(pdf_file):
    pdf = fitz.open(pdf_file)
    pdf_page = pdf[0]
    pix = pdf_page.get_pixmap()
    x = pix.width
    y = pix.height
    return x,y

#次のPDFページを表示
def get_next_page(pdf_file, page_num, page_count):
    if page_num -1 > page_count:
        page_count += 1
        data = pdf_to_image(pdf_file, page_count)
        return data, page_count
    else:
        page_count = 0
        data = pdf_to_image(pdf_file, page_count)
        return data, page_count

#前のPDFページを表示
def get_prev_page(pdf_file, page_num, page_count):
    if  page_count > 0:
        page_count -= 1
        data = pdf_to_image(pdf_file, page_count)
        return data, page_count
    else:
        page_count = page_num -1
        data = pdf_to_image(pdf_file, page_count)
        return data, page_count

#ハンコを描画
def create_circle(canvas,radius=25):

    center_x = 50  # 円の中心のX座標
    center_y = 50  # 円の中心のY座標
    color = 'white'  # 円の色
    canvas.create_oval(center_x - radius, center_y - radius,
                        center_x + radius, center_y + radius,
                        outline='red', 
                        width=2, 
                        tag='circle'
                        )

#ハンコの中の名前を描画
def embedded_name(canvas, name='名前', size=15):
    canvas.create_text(50,50, text=stamp_set_name(name), fill='red', font=('Helvetica', size), tag='text')

#名前の文字を縦にする
def stamp_set_name(name):
    var_name = ""
    for i in range(len(name)):
        if i != len(name) -1 :
            var_name += f'{name[i]}\n'
        else: var_name += f'{name[i]}'
    return var_name

#pdfを分割して保存
def four_split_pdf(input_path, output_path, sep=4):
    for i in range(sep):
        doc = fitz.open(input_path)
        doc.select([i])
        doc.save(f"{output_path}/test-page-copied{i}.pdf") 
        doc.close()

#pdfファイルに捺印する
def stamp_in_viewer(loc_x,loc_y,input_path, page_count, radius):
    doc = fitz.open(input_path)
    rect = (loc_x - radius ,loc_y - radius,loc_x + radius, loc_y + radius)
    img = open("temp/test.png", "rb").read()
    for i,page in enumerate(doc):
        if i == page_count:
            page.insert_image(rect, stream=img)
            doc.save(f'temp/test.pdf')
        else:pass
    return 'temp/test.pdf'

#はんこの画像を作る
def create_stamp_png(canvas, canvas_widget, circle_radius):
    #画面上のcanvasの絶対位置
    abs_x = canvas_widget.winfo_rootx()
    abs_y = canvas_widget.winfo_rooty()

    #canvasのwidthとheight
    width =  canvas.winfo_width()
    height =  canvas.winfo_height()

    out_circle = 50 - int(circle_radius)

    left_x = abs_x + out_circle
    left_y = abs_y + out_circle

    right_x = left_x + width - 2*out_circle + 1
    right_y = left_y + height - 2*out_circle + 1

    #png画像を生成
    image = ImageGrab.grab().crop((left_x, left_y, right_x, right_y))
    image.save('temp/test.png')

def embed_a_stamp_on_viewer(x, y, page_num, stamp_path:str, pdf_file:str):
    data = pdf_to_image(pdf_file, page_num)
    im1 = Image.open(data)
    im2 = Image.open(stamp_path)

    back_im = im1.copy()
    back_im.paste(im2, (x, y))
    back_im.save('temp/test0.png', quality=95)

    return 'temp/test0.png'

def check_input_file(input_file):
    file_name = os.path.basename(input_file)
    if file_name[:5] == '注文書一式':
        return True
    else: return False

def create_temp_folder():
    if not os.path.isdir('temp'):
        os.mkdir('temp')
    else: pass

def main():
    layout = [[sg.Frame('I/O',frame_1, size=(400,2000)),sg.Frame('preview', frame_2, size=(600,800), expand_x=True, expand_y=True, element_justification='center', key='-frame2-')]]

    window = sg.Window('PDF分割ツール1.0.0', layout, size=(1000,800), resizable=True, return_keyboard_events=True,)

    page_num = 0
    page_count = 0
    flag = False
    name = '名前'
    circle_text_init = True
    stamp_flag = False

    create_temp_folder()

    while True:
        event, values = window.read(timeout=100)
        canvas = window['-sample-'].tk_canvas
        root = window.TKroot
        frame_2_width, frame_2_height = window['-frame2-'].get_size()

        if circle_text_init:
            create_circle(canvas)
            embedded_name(canvas)
            circle_text_init = False

        if event == sg.WIN_CLOSED:
            break

        #pdfファイルをビューに表示
        if event =='-input-':
            input_path = values['-input-']
            if check_input_file(input_path):
                data = pdf_to_image(input_path, page_count)
                page_num = get_page_PDF(input_path)
                pdf_width, pdf_height = get_size_pdf(input_path)
                window['IMAGE'].update(data=data)

                window['-page-'].update(f'1 / {page_num}')
                window['前へ'].update(disabled=False)
                window['次へ'].update(disabled=False)
                flag = True
            else:sg.popup('入力されたファイルに問題があります。"注文書一式"からはじまるPDFファイルを選択してください')

        #ページをスクロール
        if event in ('次へ', "MouseWheel:Down") and flag:
            data, page_count = get_next_page(values['-input-'], page_num, page_count)
            window['IMAGE'].update(data=data)
            window['-page-'].update(f'{page_count+1}/ {page_num}')

        if event in ('前へ', 'MouseWheel:Up') and flag:
            data, page_count = get_prev_page(values['-input-'], page_num, page_count)
            window['IMAGE'].update(data=data)
            window['-page-'].update(f'{page_count+1}/ {page_num}')

        #pdfファイルに捺印し四つに分割し保存の処理
        if event == '-save-':
                if values['-output-']:
                    data = four_split_pdf('temp/test.pdf', values['-output-'])

                else:sg.popup('出力先のフォルダを選択してください')

        #ハンコ生成
        if event == '-generate-':
            name = values['-name-']
            name_size = int(values['-text_size-'])
            canvas.delete('circle')
            canvas.delete('text')
            create_circle(canvas, int(values['-slider-']))
            embedded_name(canvas, name if name != "" else '名前', name_size)
            circle_radius = values['-slider-'] + 1
            canvas_widget = window['-sample-'].Widget
            canvas.update()
            create_stamp_png(canvas, canvas_widget, circle_radius)
            stamp_flag = True

        if event == '-slider-':
            canvas.delete('circle')
            create_circle(canvas, int(values['-slider-']))

        if event == '-text_size-' :
            canvas.delete('text')
            embedded_name(canvas, name if name != "" else '名前', int(values['-text_size-']))

        if event == 'IMAGE' and flag:
            if stamp_flag:
                widget = window["IMAGE"].Widget
                x = widget.winfo_pointerx() - widget.winfo_rootx()
                y = widget.winfo_pointery() - widget.winfo_rooty()
                path = values['-input-']
                radius = int(values['-slider-'])
                viewer_path = stamp_in_viewer(x, y, path, page_count, radius)
                data = pdf_to_image(viewer_path, page_count)
                page_num = get_page_PDF(viewer_path)

                window['IMAGE'].update(data)
            else:
                sg.popup('先にハンコを作成してください')

        if flag and frame_2_width > pdf_width and frame_2_height > pdf_height:
            window['-warning-'].update(text_color='green')
            window['-save-'].update(disabled=False)

        else:
            window['-warning-'].update(text_color='red')
            window['-save-'].update(disabled=True)

        if event and not flag:
             continue

    #一時ファイル削除
    shutil.rmtree('temp')

    window.close()

if __name__ == "__main__":
    main()


終わりに

プログラムを停止させると、生成したハンコも削除されていまうため再利用できるようにしたり、プレビュー画面で捺印後にページ移動すると、押したはんこが消えてしまい押しなおす必要があるためそのあたりもまだまだ改善の余地があると感じました。
そして今回はGUIツールとして実装しましたが、DjangoやFlaskといったフレームワークを使い、Webアプリとして実装してみても面白そうだなと思いました。
ただ友人はすごく喜んでくれましたし、モノづくりに夢中になれる楽しさを再確認できたいい経験になりました。

今後も日々精進していきたいと思います。最後までお読みいただきありがとうございました。


参照資料

PySimpleGUIのドキュメント:

https://docs.pysimplegui.com/

PyMuPDFのドキュメント:

https://pymupdf.readthedocs.io/ja/latest/

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?