【Python】PDF編集アプリを開発

PDF編集アプリの作り方解説ページのアイキャッチ

このページにはプロモーションが含まれています

今回は PDF 編集アプリを tkinter 等のライブラリを駆使して使用していこうと思います!

PDF 編集アプリといっても、テキストを PDF に挿入したり、線と長方形を PDF に描画するくらいですが、それでも下記について学ぶことができると思います。

  • PDF の読み込み
  • tkinter のキャンバスの使い方
  • PDF ヘのテキストや図形の描画の仕方
  • PDF の保存の仕方

特にテキストに関してはちょっと位置やサイズがズレたりと作りが甘いところもあるのですが、上記を学ぶ上では十分な内容になっていると思います!

作成する PDF 編集アプリ

今回作成する PDF 編集アプリどのようなものであるかを最初にを説明しておきます。

アプリの画面

今回作成するアプリの画面は下の図のようになります。

アプリの画面

スポンサーリンク

PDF の読み込みと表示

本アプリでは PDF をファイル選択画面から読み込みが行えるようにしています。

さらに読み込んだ PDF をアプリの左側に表示できるようにしています。

PDFの表示

複数ページから構成される PDF においては、アプリの右側の「次ページ」ボタンを押すことで次のページを、「前ページ」ボタンを押すことで前のページを表示できるようにしています。

スポンサーリンク

PDF の編集

さらに、画面の右側の「テキスト」「線」「長方形」のボタンを選択した後、画面左側に表示されている PDF 上に対してマウス操作することで、選択した図形(or テキスト)を PDF に描画できるようにしています。

例えば「テキスト」ボタンを押して選択状態にして、

図形の選択

さらに PDF 上でマウスをクリックしてマウスを動かすと、マウスクリックした位置からマウスの現在位置に対してテキストボックスが作成されます(マウスのクリックを離すとテキストボックスが確定します)。

テキストボックスの配置

このテキストボックスに文字列を入力することで、PDF にテキストを描画することができます。

描画するテキストの設定

PDF の保存

アプリ右側の「保存」ボタンをクリックすれば、読み込んだ PDF 上に図形を描画した PDF を保存することができます。

PDFの保存

ここでもファイル選択画面でファイル名を入力することで、そのファイル名で PDF を保存することができます。

スポンサーリンク

PDF 編集アプリの作り方

続いて PDF 編集アプリの作り方について解説していきます。

アプリとしてはちょっと結構規模が大きいので、ポイントだけかいつまんで説明していきます。

アプリの画面

アプリの画面は tkinter で作成します。

アプリ左側の画像を表示する部分は tkinter の Canvas クラスで、右側のボタンは tkinter の Button クラスで作成します。

アプリを構成するウィジェット

tkinter の Button クラスについては下記ページで、

ボタンウィジェット作成解説ページのアイキャッチ Tkinterの使い方:ボタンウィジェット(Button)の使い方

キャンバスやボタン等のウィジェットの配置については下記ページで解説しています。

ウィジェット配置方法解説ページのアイキャッチ Tkinterの使い方:ウィジェットの配置(pack・grid・place)

これらについて詳しく知りたい方は是非リンク先のページを参照していただければと思います!

アプリの画面を構成するためにウィジェットの配置を行う処理は、最後に紹介するスクリプトでは PdfEditor クラスの create_widgets メソッドで行っています。

スポンサーリンク

PDF の読み込みと表示

PDF の読み込みと表示は下記の3つのステップで行うようにしています。

  • ファイル選択画面の表示
  • PDF データの画像への変換
  • 画像のキャンバスへの描画

ファイル選択画面の表示

「読み込み」ボタンを押した時にファイル選択画面が表示されるように、tkinter.filedialog を利用しています。

ファイル選択画面の表示については下記ページで詳細を解説していますので、ここについて詳しく知りたい方はコチラを参考にしていただければ幸いです。

ファイル選択画面表示の解説ページアイキャッチ Python でファイル選択画面を表示する

ファイル選択画面を表示する処理は、最後に紹介するスクリプトでは PdfEditor クラスの file_read メソッドで行っています。

PDF データの画像への変換

続いてファイル選択画面でユーザーが選択した PDF をアプリ上に表示していきます。

ただし tkinter では残念ながら PDF をそのまま表示することはできません。

tkinter で表示できる画像は tkinter 画像オブジェクトのみです。

したがって、下記の手順を踏んで PDF から tkinter 画像オブジェクトへの変換を行っています。

  1. PDF を PIL 画像に変換する
  2. PIL 画像のリサイズ
  3. リサイズ後の PIL 画像を tkinter 画像に変換する

2. はキャンバスに合わせて画像をリサイズするだけで、PIL を利用すれば resize メソッドにより行うことができます。

3. の PIL 画像を tkinter 画像に変換する方法は下記ページで詳細を解説していますので、必要に応じて読んでいただければと思います。

画像オブジェクト変換方法の解説ページアイキャッチ 【Python】PIL ⇔ OpenCV2 ⇔ Tkinter 画像オブジェクトの相互変換

「1. PDF を PIL 画像に変換する」を行うにはライブラリを利用するのが手っ取り早いです。

PDF を画像に変換する処理は RIP やレンダリングと呼ばれ、PDF の構造を理解し、その構造を紐解いてさらに画像として描画するような高度な知識・技術が要求されます。

なので、今回はライブラリを利用して簡単に画像変換を実現したいと思います。

使用するライブラリは pdf2image と呼ばれるものです。

pdf2image は PDF ファイルをページ単位の PIL 画像のリストに変換してくれるライブラリです。

pdf2image は下記コマンドでインストールできると思います。

pip install pdf2image

さらに pdf2image を Python から利用するには poppler と呼ばれるソフトも必要とのことでしたので、私は下記コマンドでインストールしました。

brew install poppler

poppler のインストールについては下記ページあたりが詳しいと思います。

Poppler : PDFのコマンドラインツール

上記2つがインストールされれば、PDF から PIL 画像への変換は下記により行うことができます。

PDFからPIL画像への変換
from pdf2image import convert_from_path

# 表示用にPDFをPIL画像データのリストに変換
pil_images = convert_from_path(
	pdf_path,
	poppler_path='/usr/local/bin',
	dpi=200
)

実行すると、各ページを変換した PIL 画像のリストが返却されます。

convert_from_path で指定している引数の詳細は下記になります。

  • pdf_path:画像に変換したい PDF ファイルへのファイルパス
  • poppler_path:インストールした poppler 実行ファイルが置かれているフォルダへのパス
  • dpi:変換後画像の解像度

poppler へ PATH が既に通っている場合は poppler_path の指定は不要です(私は不要でした)。

dpi は dot / inch の略で、1インチあたりのドット数(ピクセル数)を指定する引数になります。

値が大きいと画像が綺麗になりますが、その分必要なメモリが多くなったり、変換時にかかる時間が長くなったりします。

PIL 画像が取得できれば、あとは 2. のリサイズと 3. の tkinter 画像への変換を行えば、キャンバスに画像を描画する準備が整います。

PDF を読み込んで画像変換する処理は、最後に紹介するスクリプトでは Pdf クラスの read メソッドで行っています。

画像のキャンバスへの描画

キャンバスのサイズに合わせてリサイズされた tkinter 画像を用意できれば、あとは tkinter.Canvas クラスの create_image メソッドを実行することで、PDF のアプリ上への表示を実現することができます。

キャンバスへの描画
# self.canvasはCanvasクラスのインスタンス
self.canvas.create_image(
	0, 0,
	image=tk_image,
	anchor=tkinter.NW,
	tag="show_image"
)

image に描画したい tkinter 画像オブジェクトを指定します。

第1引数&第2引数でキャンバスの左上を指定し、anchor=tkinter.NW の指定により画像の左上が第1引数&第2引数に配置されるように画像が配置されることになるので、tk_image がキャンバスの左上に寄せて描画されることになります。

キャンバスへの画像の描画

この画像の描画は、ファイル読み込み時のみだけでなく、「前ページ」ボタンや「次ページ」ボタンへのクリックにより次のページ表示が要求された際にも実行するようにしています。

最後に紹介するスクリプトにおいては、この画像のキャンバスへの描画処理を PdfEditor クラスの file_read メソッド・prev_page メソッド・next_page メソッドで行っています。

スポンサーリンク

PDF の編集

続いて表示した画像に対して図形描画の編集が行えるようにアプリを仕立てていきます。

これを実現するために大きく分けて下記の3つの処理を行っています。

  • 描画図形の選択
  • 画像への図形描画
  • 図形の記憶・削除・再描画

描画図形の選択

冒頭でも触れたように、このアプリでは描画する図形として下記の3つを選択できるようにしています。

  • テキスト
  • 長方形

どの図形を描画するかをボタンで選べるようにボタンウィジェットを用意し、これらのボタンがクリックされた時に描画する図形を切り替えるようにしています。

図形を選択するボタン

基本的には、それぞれのボタンウィジェットに対して下記のように command を設定し、設定した関数の中で描画する図形を切り替えるようにすることで、ボタンがクリックされる度に図形が切り替わるようにしているだけです。

  • 「テキスト」ボタン:text_mode
  • 「線」ボタン:line_mode
  • 「長方形」ボタン:rect_mode

ただし、どの図形が選択されているかが分かるように、描画モードに設定されている図形に対するボタンの statetkinter.ACTIVE に設定するようにしています。

このあたりは最後に紹介するスクリプトのおける PdfEditor クラスのメソッドである下記を参考にしていただければと思います。

  • text_mode
  • line_mode
  • rect_mode

画像への図形の描画

描画する図形が選択された状態で、表示されている PDF 画像に対してマウスクリック&クリックした状態でマウス移動することにより、選択した図形を描画するようにしています。

これは下記のようにマウスクリック・マウス移動・マウスクリック終了のイベントに対してイベントハンドラを設定することで実現しています。

イベント設定
# イベントを再設定
self.canvas.bind("<ButtonPress>", self.click)
self.canvas.bind("<Motion>", self.motion)
self.canvas.bind("<ButtonRelease>", self.release)

イベントについて詳しく知りたい方は下記で解説していますので是非こちらも読んでみてください。

イベント処理解説ページのアイキャッチ Tkinterの使い方:イベント処理を行う

要は上記のイベント設定により、イベント発生時に下記のメソッドが実行されることになります。

  • マウスクリック時:click
  • マウス移動時:motion
  • マウスクリック終了release

そして、これらのメソッドでは主に下記を行うようにしています。

  • click:クリック開始フラグ ON・クリック位置記憶
  • motion:クリック位置から現在位置に対して図形描画
  • release:クリック位置から現在位置に対して図形描画(図形の確定)

テキスト・線・長方形それぞれの図形描画をより具体的に言うと、下記の処理になります。

  • テキストの描画:tkinter Entry ウィジェットを作成
  • 線の描画:Canvas クラスの create_line メソッド実行
  • 長方形の描画:Canvas クラスの create_rectangle メソッド実行

tkinter Canvas クラスでもテキストを描画することは可能なのですが、入力されたテキストの取得を行うのに Entry クラスのウィジェットを作成する方が便利なので、コチラを採用しています。

図形の描画に関しては、最後に紹介スクリプトでは Text クラス・Line クラス・Rect クラスに責務を持たせています。

図形描画関連の処理については上記のクラスを参考にしていただければと思います。

より具体的には各クラスにおける create メソッドで図形の描画を行っています。

図形の記憶・削除・再描画

上記によってアプリ上で描画した図形を、最終的に PDF にも描画することがこのアプリのメインの機能です。

PDF 描画時に、アプリ上で描画した図形の情報を参照できるように、図形の描画を行う TextLineRect それぞれのクラスでリストを持たせておき、このリストに描画した図形の情報を記憶させるようにしています。

そして、「保存」ボタンが押された時には、このリストの情報を参照して図形を PDF に描画させるようにしています。

最後に紹介するスクリプトにおいては、TextLineRect それぞれのクラスの all_list 属性が、上記の図形の情報を記憶するリストになっています。

all_list は2次元リストになっており、各ページ単位で図形の情報を記憶するようにしています。

2次元リストがページ単位で図形を管理する様子

また、PDF が複数ページの場合は「前ページ」「次ページ」ボタンによりアプリ上に表示するページを変更できるようにしています。

このページの変更時に、変更前のページ描画した図形が残っているとイマイチなので、ページ変更時に変更前のページに描画した図形は削除するようにしています。

Canvas に描画した図形の削除は、下記のように delete メソッドを実行することで実現することができます。

図形の削除
# canvasはCanvasクラスのインスタンス
# tagは描画した図形に付けられたタグ
canvas.delete(tag)

ただし、テキストは Entry ウィジェットで描画していますので削除の仕方が異なります。

place メソッドにより配置したウィジェットの削除は下記のように place_forget メソッドを実行することで実現することができます。

テキストの削除
# textはEntryクラスのインスタンス
text.place_forget()
MEMO

place_forget はあくまで画面からの表示を消すだけで、プログラム内部ではウィジェットの実体は残ったままです

ですので、再度 place メソッドで配置してやれば、以前と同じ設定のままテキストを再描画することができます

また、「前ページ」ボタンや「次ページ」ボタンで既に図形を描画したページの表示に戻った際には、そのページに描画済みの図形を再描画するようにしています。

Canvas に描画していた図形の再描画は、線であれば create_line メソッドで、長方形であれば create_rectangle を再度実行することで実現することができます。

ウィジェット自体の再表示は下記のように place メソッドを再度実行だけで実現することができます。

テキストの再表示
# textはEntryクラスのインスタンス
text.place(x, y)

再描画する際には、以前に描画した時の座標情報が必要になりますので、all_list には描画時に使用した座標の情報を記憶させるようにしています。

図形の情報の記憶、図形の削除、図形の再描画のそれぞれの処理は、最後に紹介するスクリプトでは TextLineRect クラスのそれぞれ下記メソッドで行っています。

  • 図形の情報の記憶:create
  • 図形の削除:remove
  • 図形の際描画:place

具体的にどのようなことを行っているかは上記メソッドの、特に all_list を使用しているあたりを参考にしてください。

PDF の保存

最後にアプリ上で描画された図形を PDF に描画し、PDF として保存する処理を行なっていきます。

この PDF への図形の描画や PDF の保存は下記ページで詳細を説明しているので、これらに関して知りたい方は是非こちらのページを読んで頂ければとおもいます。

PDF編集アプリの作り方解説ページのアイキャッチ 【Python】PDF編集アプリを開発

ここではこのアプリとして仕立てる上で重要なポイントのみ説明したいと思います。

ポイントは下記の3つになります。

  • 画像上で描画された図形に基づいて座標を設定する
  • 描画された図形分の描画を実行する
  • アプリに表示されている画像がリサイズされていることを考慮して座標を設定する

最後に紹介するスクリプトにおいては、これらの処理は全て PDF クラスの write メソッドで行っています。

画像上で描画された図形の情報に基づいて描画する

このアプリでは、アプリに表示された画像に対してユーザーが描画した図形を PDF 上に描画することを目的としています。

ですので、PDF に図形やテキストを描画する ReportLab の linerectdrawString メソッドには、ユーザーが描画した図形の座標やテキストの内容を指定する必要があります。

つまり、図形やテキストを PDF に描画する際には、ユーザーが描画した図形やテキストの座標やテキストの内容を参照する必要があります。

一方で、図形の記憶・削除・再描画で説明したように図形の再描画ができるようにリストで図形の情報(座標情報など)を管理するようにしています。

ですので、このリストを PDF への図形やテキスト描画時にも利用すれば、ユーザーが描画した位置に描画することができます。

描画された図形分の描画を実行する

下記ページの最後では、複数ページの PDF に対して描画を行なうためにページ数分のループを行うスクリプトを紹介しています。

PDF編集アプリの作り方解説ページのアイキャッチ 【Python】PDF編集アプリを開発

これに対し、本アプリではページごとに複数の図形が描画されることもあるので、各ページに対して描画されている図形の数分のループも必要になります(つまり二重ループが必要)。

前述の通り、図形ごとに図形の情報をリストで管理するようにしていますので、このリストに対するループを行えば、上記の二重ループを実現することができます。

図形分のループを行う
for page in self.pages:# ページに対するループ

	# 〜略〜

	# text_info_list は各ページに描画したテキストの情報を記録するリスト
	for text_info in text_info_list[i]:# テキストに対するループ

アプリに表示されている画像がリサイズされていることを考慮して座標を設定する

ただし、アプリに表示している画像は キャンバスのサイズに合わせてリサイズをしていますので、そのリサイズを考慮して座標を設定する必要があります。

このリサイズを考慮した座標は、ユーザーが描画した座標に「PDF のサイズ / 画像のサイズ」を掛け算することで求めることができます。

例えば線を描画する際には、下記のように座標を計算したのちに line メソッドで線を描画するようにしています。

リサイズを考慮した座標設定
# 座標を取得
# line_infoは線の情報を格納したリスト
x1, y1, x2, y2 = line_info["place"]

# PDFの座標に変換(pp.wはPDFのページの幅、pp.hはPDFのページの高さ)
target_x1 = x1 * pp.w / self.images[0].width()
target_x2 = x2 * pp.w / self.images[0].width()
target_y1 = pp.h - y1 * pp.h / self.images[0].height()
target_y2 = pp.h - y2 * pp.h / self.images[0].height()

# 線描画
cc.line(target_x1, target_y1, target_x2, target_y2)

PDF 編集アプリのスクリプト

最後に PDF 編集アプリのスクリプトを紹介しておきます。

スポンサーリンク

スクリプト

スクリプトは下記のようになります。

PDF編集アプリ
import tkinter
import tkinter.filedialog
from PIL import Image, ImageTk
import os
from pdfrw import PdfReader
from pdfrw.buildxobj import pagexobj
from pdfrw.toreportlab import makerl
from reportlab.pdfgen import canvas
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.pdfbase import pdfmetrics
from reportlab.lib.units import mm
from pdf2image import convert_from_path

# キャンバスのサイズ設定
CANVAS_WIDTH = 400
CANVAS_HEIGHT = 600

# 図形の色の設定
TEXT_COLOR = "red"
LINE_COLOR = "green"
RECT_COLOR = "blue"

class Figure():
	'''図形を描画するクラス'''

	def __init__(self, canvas, num_page):
		'''
		初期化メソッド
		canvas:キャンバスウィジェット
		num_page:ページ数
		'''

		# 図形の描画先ウィジェット設定
		self.canvas = canvas

		# 図形情報記憶用のリスト作成
		self.all_list = list(range(num_page))
		for i in range(len(self.all_list)):
			self.all_list[i] = []

		# マウス位置の初期化
		self.sx = 0
		self.sy = 0
		self.ex = 0
		self.ey = 0

	def remove(self, num_page):
		'''
		図形を削除するメソッド
		num_page:図形を削除するページ番号
		'''

		# 図形を削除するページのリストを取得
		page_list = self.all_list[num_page]

		for figure in page_list:

			# タグを指定して図形を削除
			tag = figure["tag"]
			self.canvas.delete(tag)

	def place(self, num_page):
		'''
		図形を再描画するメソッド
		num_page:図形を再描画するページ番号
		'''

		# 図形を描画するページのリストを取得
		page_list = self.all_list[num_page]
		for figure in page_list:
			tag = figure["tag"]
			place = figure["place"]

			# 図形を際描画
			self.create_method(place, tag=tag)

	def motion(self, sx, sy, ex, ey):
		'''
		マウス移動時に図形を一時的に描画するメソッド
		sx, sy, ex, ey:図形の座標
		'''

		# 一旦図形を削除
		self.canvas.delete("drawing_figure")

		# 指定された座標に図形を描画
		self.create_method(
			sx, sy, ex, ey,
			tag="drawing_figure"
		)

	def create(self, sx, sy, ex, ey, num_page):
		'''
		マウスクリック終了時に図形を描画するメソッド
		sx, sy, ex, ey:図形の座標
		num_pag:図形の描画先のページのページ番号
		'''

		# 図形を一旦削除
		self.canvas.delete("drawing_line")

		# タグを生成
		tag = self.prefix + str(num_page) + "_" + str(len(self.all_list[num_page]))
		
		# 図形を描画
		self.create_method(
			sx, sy, ex, ey,
			tag=tag
		)
		
		# 図形の情報をリストに記憶
		info = {
			"tag":tag,
			"place":(sx, sy, ex, ey),
		}
		self.all_list[num_page].append(info)
	
	def get_list(self):
		'''
		図形の情報を格納したリストを取得するメソッド
		'''

		return self.all_list

class Text(Figure):
	'''テキストを描画するクラス'''
	def __init__(self, canvas, num_page):
		super().__init__(canvas, num_page)

		# テキストならではの情報を設定
		self.prefix = "rect_"
		self.create_method = self.canvas.create_rectangle

	def remove(self, num_page):
		'''
		テキストを削除するメソッド
		num_page:テキストを削除するページ番号
		'''

		# テキストを削除するページのリストを取得
		page_list = self.all_list[num_page]
		for text in page_list:

			# ウィジェットを取得してアプリから除去
			obj = text["obj"]
			obj.place_forget()

	def place(self, num_page):
		'''
		テキストを再描画するメソッド
		num_page:テキストを再描画するページ番号
		'''

		# テキストを再描画するページのリストを取得
		page_list = self.all_list[num_page]
		for text in page_list:

			# ウィジェットを取得して再配置
			obj = text["obj"]
			place = text["place"]
			obj.place(x=place[0], y=place[1])

	def motion(self, sx, sy, ex, ey):
		'''
		マウス移動時に図形を一時的に描画するメソッド
		sx, sy, ex, ey:図形の座標
		'''

		# 長方形を一旦削除
		self.canvas.delete("text_box")

		# 長方形を指定された座標に描画
		self.canvas.create_rectangle(
			sx, sy, ex, ey,
			tag="text_box"
		)

	def create(self, sx, sy, ex, ey, num_page):
		'''
		テキストを描画するメソッド
		sx, sy, ex, ey:テキストの座標
		num_page:テキストの描画先のページのページ番号
		'''

		# テキストを表していた長方形を削除
		self.canvas.delete("text_box")

		# フォントサイズを取得
		font_size = self.font_size_adjust(max(sy, ey) - min(sy, ey))
		
		# 横幅(文字数)をザックリ計算
		if font_size != 0:
			width = (max(sx, ex) - min(sx, ex)) // font_size * 2
		else:
			width = 1

		# Entryウィジェットを作成
		text = tkinter.Entry(
			self.canvas,
			foreground=TEXT_COLOR,
			width=width,
			highlightthickness=0,
			font=("", font_size)
		)

		# Entryウィジェットを配置
		text.place(x=min(sx, ex), y=min(sy, ey))

		# テキストの情報をリストに記録
		info = {
			"obj":text,
			"place":(sx, sy, ex, ey),
			"size":font_size
		}
		self.all_list[num_page].append(info)
	
	def get_list(self):
		'''
		テキストの情報を格納したリストを取得するメソッド
		'''

		return self.all_list

	def font_size_adjust(self, size):
		'''
		フォントサイズの微調整を行うメソッド
		'''

		return int(size / 1.2)

class Line(Figure):
	'''線を描画するクラス'''

	def __init__(self, canvas, num_page):
		super().__init__(canvas, num_page)

		# 線ならではの情報を設定
		self.prefix = "line_"
		self.create_method = self.canvas.create_line

class Rect(Figure):
	'''長方形を描画するクラス'''

	def __init__(self, canvas, num_page):
		super().__init__(canvas, num_page)

		# 長方形ならではの情報を設定
		self.prefix = "rect_"
		self.create_method = self.canvas.create_rectangle

class PdfEditor():
	'''
	図形編集アプリクラス
	'''

	def __init__(self, master):
		self.master = master


		self.canvas_width = CANVAS_WIDTH
		self.canvas_height = CANVAS_HEIGHT

		self.pressing = False
		
		self.create_widgets()

		self.text = None
		self.line = None
		self.rect = None
		self.figure = None

	def create_widgets(self):
		'''
		ウィジェットを作成として配置するメソッド
		'''

		self.canvas = tkinter.Canvas(
			self.master,
			width=self.canvas_width,
			height=self.canvas_height,
			highlightthickness=0
		)
		self.canvas.grid(
			column=0,
			row=0
		)

		self.operation_frame = tkinter.Frame(
			self.master
		)
		self.operation_frame.grid(
			column=1,
			row=0
		)

		self.read_button = tkinter.Button(
			self.operation_frame,
			text="読み込み",
			command=self.file_read
		)
		self.read_button.grid(
			column=0,
			row=0
		)

		self.write_button = tkinter.Button(
			self.operation_frame,
			text="保存",
			command=self.write,
			state=tkinter.DISABLED
		)
		self.write_button.grid(
			column=0,
			row=1
		)
		
		self.text_button = tkinter.Button(
			self.operation_frame,
			text="テキスト",
			command=self.text_mode,
			state=tkinter.DISABLED
		)
		self.text_button.grid(
			column=0,
			row=2
		)

		self.line_button = tkinter.Button(
			self.operation_frame,
			text="線",
			command=self.line_mode,
			state=tkinter.DISABLED
		)
		self.line_button.grid(
			column=0,
			row=3
		)

		self.rect_button = tkinter.Button(
			self.operation_frame,
			text="長方形",
			command=self.rect_mode,
			state=tkinter.DISABLED
		)
		self.rect_button.grid(
			column=0,
			row=4
		)

		self.prev_button = tkinter.Button(
			self.operation_frame,
			text="前ページ",
			command=self.prev_page,
			state=tkinter.DISABLED,
		)
		self.prev_button.grid(
			column=0,
			row=5
		)

		self.next_button = tkinter.Button(
			self.operation_frame,
			text="次ページ",
			command=self.next_page,
			state=tkinter.DISABLED,
		)
		self.next_button.grid(
			column=0,
			row=6
		)

	def change_prev_next_button_state(self):
		'''
		前ページ・次ページボタンの状態を更新するメソッド
		'''

		# 前ページボタンの状態設定
		if self.show_page > 0:
			self.prev_button.config(state=tkinter.NORMAL)
		else:
			self.prev_button.config(state=tkinter.DISABLED)

		# 次ページボタンの状態設定
		if self.show_page < self.num_page - 1:
			self.next_button.config(state=tkinter.NORMAL)
		else:
			self.next_button.config(state=tkinter.DISABLED)

	def prev_page(self):
		'''
		前ページボタンが押された時の処理
		'''

		# 表示中の図形を削除
		self.text.remove(self.show_page)
		self.line.remove(self.show_page)
		self.rect.remove(self.show_page)

		# 表示中のページ番号を更新
		self.show_page -= 1

		# 前ページをキャンバスに描画
		self.canvas.create_image(
			0, 0,
			image=self.pdf.get_image(self.show_page),
			anchor=tkinter.NW,
			tag="show_image"
		)

		# 前ページに既に描画している図形を再描画
		self.text.place(self.show_page)
		self.line.place(self.show_page)
		self.rect.place(self.show_page)

		# 前ページ・後ページの状態を更新
		self.change_prev_next_button_state()

	def next_page(self):
		'''
		前ページボタンが押された時の処理
		'''

		# 表示中の図形を削除
		self.text.remove(self.show_page)
		self.line.remove(self.show_page)
		self.rect.remove(self.show_page)

		# 表示中のページ番号を更新
		self.show_page += 1

		# 次ページをキャンバスに描画
		self.canvas.create_image(
			0, 0,
			image=self.pdf.get_image(self.show_page),
			anchor=tkinter.NW,
			tag="show_image"
		)

		# 次ページに既に描画している図形を再描画
		self.text.place(self.show_page)
		self.line.place(self.show_page)
		self.rect.place(self.show_page)

		# 前ページ・後ページの状態を更新
		self.change_prev_next_button_state()

	def change_figure_state(self, text, line, rect):
		'''
		図形選択ボタンの状態を更新するメソッド
		text:テキストボタンの状態
		line:線ボタンの状態
		rect:長方形ボタンの状態
		'''

		# 各ボタンの状態を設定
		self.text_button.config(
			state=text
		)
		self.line_button.config(
			state=line
		)
		self.rect_button.config(
			state=rect
		)
		

	def text_mode(self):
		'''図形をテキストに設定するメソッド'''

		# 一旦イベントを全てアンバインドする
		self.canvas.unbind("<ButtonPress>")
		self.canvas.unbind("<Motion>")
		self.canvas.unbind("<ButtonRelease>")
		
		if type(self.figure) == Text:
			# 現在選択されている図形がテキストの場合

			# 全ボタンを通常状態に設定
			self.change_figure_state(
				tkinter.NORMAL,
				tkinter.NORMAL,
				tkinter.NORMAL,
			)

			# 選択されている図形をNoneに設定
			self.figure = None
		else:
			# 現在選択されている図形がテキスト以外の場合

			# イベントを再設定
			self.canvas.bind("<ButtonPress>", self.click)
			self.canvas.bind("<Motion>", self.motion)
			self.canvas.bind("<ButtonRelease>", self.release)
		
			# テキストボタンをアクティブに設定
			self.change_figure_state(
				tkinter.ACTIVE,
				tkinter.NORMAL,
				tkinter.NORMAL,
			)
			
			# 描画図形オブジェクトをTextクラスのオブジェクトに設定
			self.figure = self.text

	def line_mode(self):
		'''図形を線に設定するメソッド'''

		# 一旦イベントを全てアンバインドする
		self.canvas.unbind("<ButtonPress>")
		self.canvas.unbind("<Motion>")
		self.canvas.unbind("<ButtonRelease>")
		
		if type(self.figure) == Line:
			# 現在選択されている図形が線の場合

			# 全ボタンを通常状態に設定
			self.change_figure_state(
				tkinter.NORMAL,
				tkinter.NORMAL,
				tkinter.NORMAL,
			)

			# 選択されている図形をNoneに設定
			self.figure = None
		else:
			# 現在選択されている図形が線以外の場合

			# イベントを再設定
			self.canvas.bind("<ButtonPress>", self.click)
			self.canvas.bind("<Motion>", self.motion)
			self.canvas.bind("<ButtonRelease>", self.release)
		
			# 線ボタンをアクティブに設定
			self.change_figure_state(
				tkinter.NORMAL,
				tkinter.ACTIVE,
				tkinter.NORMAL,
			)
			
			# 描画図形オブジェクトをLineクラスのオブジェクトに設定
			self.figure = self.line

	def rect_mode(self):
		'''図形を長方形に設定するメソッド'''

		# 一旦イベントを全てアンバインドする
		self.canvas.unbind("<ButtonPress>")
		self.canvas.unbind("<Motion>")
		self.canvas.unbind("<ButtonRelease>")
		
		if type(self.figure) == Rect:
			# 現在選択されている図形が長方形の場合

			# 全ボタンを通常状態に設定
			self.change_figure_state(
				tkinter.NORMAL,
				tkinter.NORMAL,
				tkinter.NORMAL,
			)

			# 選択されている図形をNoneに設定
			self.figure = None
		else:
			# 現在選択されている図形が長方形以外の場合

			# イベントを再設定
			self.canvas.bind("<ButtonPress>", self.click)
			self.canvas.bind("<Motion>", self.motion)
			self.canvas.bind("<ButtonRelease>", self.release)
		
			# 長方形ボタンをアクティブに設定
			self.change_figure_state(
				tkinter.NORMAL,
				tkinter.NORMAL,
				tkinter.ACTIVE,
			)
			
			# 描画図形オブジェクトをRectクラスのオブジェクトに設定
			self.figure = self.rect

	def click(self, event):
		'''
		キャンバスクリック時の処理
		'''

		# クリック中フラグをON
		self.pressing = True

		# 座標を記憶
		self.start = (event.x, event.y)

	def motion(self, event):
		'''
		マウス移動時の処理
		'''

		if self.pressing:
			# 図形のクラスのmotionメソッド実行
			self.figure.motion(self.start[0], self.start[1], event.x, event.y)

	def release(self, event):
		'''
		マウスクリック終了時の処理
		'''

		# マウスクリック中フラグをOFF
		self.pressing = False

		# 座標を記憶
		self.end = (event.x, event.y)

		# 図形のクラスのcreateeメソッド実行
		self.figure.create(
			self.start[0], self.start[1],
			self.end[0], self.end[1],
			self.show_page
		)

	def file_read(self):
		'''
		ファイルの読み込みを行うメソッド
		'''

		# ファイル選択画面を表示してファイルパスを取得
		self.path = tkinter.filedialog.askopenfilename(
			filetypes=[
				("PDFファイル", "*.pdf"),
			],
			title="ファイル選択",
		)

		# PDFクラスのオブジェクト生成
		self.pdf = Pdf()

		# 指定されたPDFファイルをtkinter画像に変換
		size = (self.canvas_width, self.canvas_height)
		self.num_page = self.pdf.read(self.path, size)

		# ページ0の画像をキャンバスに描画
		self.show_page = 0
		self.canvas.create_image(
			0, 0,
			image=self.pdf.get_image(self.show_page),
			anchor=tkinter.NW,
			tag="show_image"
		)

		self.change_prev_next_button_state()
		self.rect_button.config(
			state=tkinter.NORMAL
		)
		self.line_button.config(
			state=tkinter.NORMAL
		)
		self.text_button.config(
			state=tkinter.NORMAL
		)
		self.write_button.config(
			state=tkinter.NORMAL
		)

		# 図形描画するオブジェクトを設定
		self.text = Text(self.canvas, self.num_page)
		self.line = Line(self.canvas, self.num_page)
		self.rect = Rect(self.canvas, self.num_page)

	def write(self):
		'''
		PDF保存を行うメソッド
		'''

		# ファイル選択ダイアログの表示
		output_path = tkinter.filedialog.asksaveasfilename(
			initialfile="output.pdf",
			defaultextension="pdf",
		)
		
		# PDFファイル保存
		self.pdf.write(
			output_path,
			self.text.get_list(),
			self.line.get_list(),
			self.rect.get_list()
		)

class Pdf():
	'''
	PDFに関する処理を行うクラス
	'''

	def __init__(self):

		# 初期設定
		self.dpi = 200
		self.images = None

	def read(self, pdf_path, size):
		'''
		PDFを画像に変換するメソッド
		pdf_path:画像に変換するPDFへのファイルパス
		size:キャンバスのサイズ
		'''

		# PDFへのパスを覚えておく
		self.pdf_path = pdf_path
		
		# 表示用にPDFをPIL画像データのリストに変換
		pil_images = convert_from_path(
			pdf_path,
			poppler_path='/usr/local/bin',
			dpi=self.dpi
		)

		# キャンバス描画用に画像をリサイズする時の拡大率計算
		x_ratio = size[0] / pil_images[0].width
		y_ratio = size[1] / pil_images[0].height
		
		# キャンバス内に収まるように小さいほうの拡大率を採用
		image_ratio = min(x_ratio, y_ratio)

		# リサイズ後のサイズを計算
		resize_size = (
			int(pil_images[0].width * image_ratio),
			int(pil_images[0].height * image_ratio)
		)

		# リサイズ後のtkinter画像をリストに追加
		self.images = []
		for pil_image in pil_images:
			# リサイズ
			resize_image = pil_image.resize(resize_size)

			# tkinter画像に変換
			tk_image = ImageTk.PhotoImage(resize_image)

			# リストに追加
			self.images.append(tk_image)

		# ページ数を返却
		return len(self.images)

	def get_image(self, num):
		'''
		画像を取得するメソッド
		num:取得する画像のページ番号
		'''

		# ページ数を超える場合はNoneを返却
		if num < 0 or num >= len(self.images):
			return None

		# 指定されたページの画像データを返却
		return self.images[num]

	def write(self, out_path, text_info_list, line_info_list, rect_info_list):
		'''
		PDFに図形を描画してファイル保存するメソッド
		out_path:保存するPDFのファイルパス
		text_info_list:テキストの情報を格納したリスト
		line_info_list:線の情報を格納したリスト
		rect_info_list:長方形の情報を格納したリスト
		'''

		# PDFを読み込む
		self.pdf = PdfReader(self.pdf_path, decompress=False)

		# ページリスト取得
		self.pages = self.pdf.pages

		# PDFデータ作成
		cc = canvas.Canvas(out_path)

		# フォントを登録
		font_name = "HeiseiKakuGo-W5"
		pdfmetrics.registerFont(UnicodeCIDFont(font_name))

		i = 0
		for page in self.pages:

			# PDFデータにページを展開
			pp = pagexobj(page)
			rl_obj = makerl(cc, pp)
			cc.doForm(rl_obj)

			for text_info in text_info_list[i]:
				# フォント設定
				cc.setFont(font_name, text_info["size"] * pp.h / self.images[0].height())

				# 座標を取得
				sx, sy, ex, ey = text_info["place"]
				
				x1 = min(sx, ex)
				x2 = max(sx, ex)
				y1 = max(sy, ey)
				y2 = min(sy, ey)

				# PDFの座標に変換
				target_x1 = x1 * pp.w / self.images[0].width()
				target_y1 = pp.h - y1 * pp.h / self.images[0].height()

				# テキストの色を設定
				cc.setFillColor(TEXT_COLOR)

				# テキスト描画
				cc.drawString(target_x1, target_y1, text_info["obj"].get())



			for line_info in line_info_list[i]:

				# 座標を取得
				x1, y1, x2, y2 = line_info["place"]
				
				# PDFの座標に変換
				target_x1 = x1 * pp.w / self.images[0].width()
				target_x2 = x2 * pp.w / self.images[0].width()
				target_y1 = pp.h - y1 * pp.h / self.images[0].height()
				target_y2 = pp.h - y2 * pp.h / self.images[0].height()
				
				# 線の色を設定
				cc.setStrokeColor(LINE_COLOR)

				# 線描画
				cc.line(target_x1, target_y1, target_x2, target_y2)

			
			for rect_info in rect_info_list[i]:

				# 座標取得
				sx, sy, ex, ey = rect_info["place"]
				
				x1 = min(sx, ex)
				x2 = max(sx, ex)
				y1 = max(sy, ey)
				y2 = min(sy, ey)

				# PDFの座標に変換
				target_x1 = x1 * pp.w / self.images[0].width()
				target_y1 = pp.h - y1 * pp.h / self.images[0].height()
				width = (x2 - x1) * pp.w / self.images[0].width()
				height = (y1 - y2) * pp.h / self.images[0].height()
			
				# 線の色を設定
				cc.setStrokeColor(RECT_COLOR)

				# 長方形描画
				cc.rect(target_x1, target_y1, width, height)

			# ページ確定
			cc.showPage()
			i += 1

		# 確定したページをPD保存
		cc.save()


app = tkinter.Tk()

# アプリの起動
pdfEditor = PdfEditor(app)

app.mainloop()

スクリプトの設定

スクリプトの下記部分で簡単な設定を行うことができます。

[codebox title="スクリプトの設定"]
# キャンバスのサイズ設定
CANVAS_WIDTH = 400
CANVAS_HEIGHT = 600

# 図形の色の設定
TEXT_COLOR = "red"
LINE_COLOR = "green"
RECT_COLOR = "blue"

CANVAS_WIDTHCANVAS_HEIGHT の設定によりキャンバスのサイズを変更できます。ご自身のディスプレイサイズや読み込む PDF のサイズに合わせて適宜変更していただければと思います。

XXXX_COLOR 設定で PDF に描画する図形の色を変更することができます。

ただしアプリ上で描画する線と長方形の色は黒色固定です…。

動作確認

アプリを起動すると下記のことを確認できると思います。

  • 「読み込み」ボタンから選択した PDF の先頭ページがアプリに表示される
  • 「テキスト」「線」「長方形」のボタンを押した後に、マウス操作で選択した図形が描画できる
  • PDF が複数ページの場合は、「前ページ」「次ページ」を押すとページが移動する(移動先のページでも図形が描画できる)
  • 図形描画後に「保存」ボタンを押すと、選択したパスに PDF が保存され、その PDF に描画した図形が反映されている

スポンサーリンク

まとめ

このページでは PDF の編集アプリの作り方について解説しました。

いろんなライブラリを使ったり、行う処理も多いので作るのは大変ですが、その分作ってみるとプログラミング力や知識が付くと思います。

是非みなさんも今回紹介したアプリを真似てみたり、拡張してみたり、 PDF を利用したいろんなアプリを作ってみたりしてみてください!

同じカテゴリのページ一覧を表示