はじめに
python-pptxとPySimpleGUIを使ってPowerPointで作ったフォーマット位置に画像を貼り付けるツールを作ったので紹介します。
開発の経緯
現職では主に製品の実証試験的な業務を担当しています。
業務の性質上、エビデンスとして画像を撮影 → PowerPoint(報告用)に貼り付ける という作業が多く発生します。
そういった作業が多くあるので既に効率化は図られていて、「枚数を指定すると一定間隔で画像を貼り付けてくれるツール」は存在します。(DXする部署が作成)
しかし、実際に報告資料を作成する際に欲しいのは決まったフォーマット・画像の配置であることが多いです。
そのため、一定間隔で貼り付けることが出来たとしてもそれをそのまま報告に使うことはほとんど出来ず、結局手作業で画像の位置を調整している人が多くいます。
そこで、上司に相談したり、職場の方に聞き込みをしたりして、
- フォーマットを自由に指定出来る
- PowerPointを使って(=誰でも使える)フォーマットを指定できる
- テキストをデータ名に置換出来たりすると嬉しい
という要素を持つツールがあれば良さそう、という結論に至ったので作りました。
だいたい3日で完成させました。
作ったもの
PowerPointで作成したフォーマットを使用して画像貼り付け・テキスト置換を行います。
主な機能
- 1データに画像が複数存在する場合、指定したラベル位置に画像を貼り付けます。
- 1データに対して複数のレイアウト(正面、背面、側面...)の画像が存在するイメージです。
- 連番になっているデータ(1データ:1画像)の場合、指定した位置に対して順番に画像を貼り付けます。
- 四角形 → 画像 への置換
- 四角形の配置 = 画像の配置
- 四角形のテキスト = 画像のラベル
- 画像はデータ名とラベルを「 _ 」で区切る
- テキストの置換
- @1 (@ + 数字) = データ名へ置換
- #1 (# + 数字) = ラベルへ置換
1データに複数画像が存在する場合(ラベル位置への貼付け)
1データに画像が複数存在する場合、ラベルの位置を指定することで画像を配置します。
入力フォーマット
ラベルを指定します。
- 1枚のスライドに1グループの場合は「ラベル名」のみ指定します。
- 1枚のスライドに複数グループが存在する場合、
_
で「連番」と「ラベル名」を区切って指定します。 - PowerPointで指定するラベル名と画像のラベル名は一致させる必要があります。
下の例では 2データ/1スライド なので、「1_右」, 「2_右」のように _
で「連番」と「ラベル名」を区切ります。
※ ラベル位置貼り付けの場合はテキスト置換を使う必要が無い(毎回ラベルが同じになるため)ので、置換ラベル(#〇〇)の順序がバラバラです。
入力画像
ねずみ_右の場合は「ねずみ」or「右」のどちらかをラベルとして指定します。
出力されるPowerPoint
1データに画像が1枚の場合(連番貼り付け)
連番になっているデータの場合、スライドを跨いで順番に画像を貼り付ける事ができます。
入力フォーマット
入力画像
fruits_1の場合は「fruits」がデータ名、「1」がラベル(連番)として扱われます。
出力されるPowerPoint
連番は0埋めの有無に関係なく、数値の大小で判定されます。
実装
動作環境
- Windows10
- Macでも動作するとは思いますが、一部機能(主にPySimpleGUI)が使えない、
pathlib
等のOSにより挙動が変わるライブラリにより影響が出る可能性があります。 Mac使ってる人にパワポ職人はいないと信じているので大丈夫でしょう
- Macでも動作するとは思いますが、一部機能(主にPySimpleGUI)が使えない、
- Python 3.10.2
- python-pptx 0.6.21
- PySimpleGUI 4.59.0
必要なライブラリをインストールします。
pipenv install python-pptx
pipenv install PySimpleGUI
python-pptx
python-pptxはその名の通りPythonからPowerPointを操作出来るライブラリです。
公式ドキュメントに基本的な使い方はだいたい載っています。
python-pptx — python-pptx 0.6.21 documentation
日本語だとこちらのサイトがわかりやすいです。
【Python×PowerPoint】python-pptxの導入~ファイル・スライド作成方法を徹底解説 | Pythonでもっと自由を
基本的な使い方
https://www.shibutan-bloomers.com/python-libraly-pptx-5/1188/より
PowerPointの基本的な構成は上のようなイメージになっています。
図形やテキストボックス等の1つ1つの要素はShapeオブジェクトとして管理されています。
Presentation
まずはPresentation
オブジェクトにアクセスします。
# 引数として渡したPowerPointを開く
prs = pptx.Presentation("./sample.pptx")
# 空のPresentationを作ることも出来る
prs = pptx.Presentation()
# saveで保存
prs.save('./sample.pptx')
Slides
ファイルを構成する全てのスライドはSlides
で管理されています。
# Slidesコレクション取得
slides = prs.slides
# 1枚目のスライドを取得
slide = slides[0]
# 末尾にスライドを追加 返り値は追加されたスライド
added_slide = slides.add_slide(side_layouts)
SlideLayout
SlideLayout
はスライドのプレースホルダです。
Presentation
からアクセスします。
主にSlides.add_slide
でスライドを追加する時に使います。
layout = prs.slide_layouts[0]
# 名前で取得することも可能
layout = prs.slides.get_by_name("白紙")
Shape
Shapes — python-pptx 0.6.21 documentation
Shape
オブジェクト(スライドが持つコンテンツ)にアクセスしたい場合、対象のオブジェクト(Slide
)のshapes
プロパティにアクセスします。
shapes = slide.shapes
shape = shapes[0]
shapeの判定
定数として定義されているMSO_AUTO_SHAPE_TYPE
やMSO_SHAPE_TYPE
を使いました。
Enumerations — python-pptx 0.6.21 documentation
from pptx.enum.shapes import MSO_SHAPE
for shape in shapes:
if shape.shape_type == MSO_SHAPE.RECTANGLE:
print("コレは四角形")
shapeの持つ情報
座標やサイズなど。
left, top, width, height = shape.left, shape.top, shape.width, shape.height
text = shape.text
# add〇〇系のメソッドで使う
shapes.add_shape(autoshape_type_id, left, top, width, height)
画像の挿入
# image_fileは画像パス
picture = Shapes.add_picture(image_file, left, top, width, height)
# トリミングできる
picture.crop_bottom = 0.25
# 回転もできる
picture.rotation = 45 # +なら時計回り -なら反時計回り
テキストボックスの挿入
テキストボックスに限りませんが、テキストを扱うためにはtext_frame
プロパティにアクセスします。
TextFrame
はparagraph
(段落)を持ちます。paragraph
ごとにフォント・書式などの設定をするイメージです。
txBox = sld0.shapes.add_textbox(left, top, width, height) #Text Box Shapeオブジェクトの追加
tf = txBox.text_frame # TextFrameオブジェクトの設定
tf.text = "This is text inside a textbox" # TextFrameオブジェクトはデフォルトで1つ段落を持つ
p = tf.add_paragraph() # paragraphオブジェクトの追加作成(2段落目)
p.text = "This is a second paragraph that's bold" # textプロパティによる文字列の設定
p.font.bold = True # font.boldプロパティによる太文字設定
python-pptxの単位
デフォルトではemu(English Metric Units)という単位を使用しています。
1 cm = 360000 emuです。
今回は座標情報の取得 → 図形挿入をするだけなので、デフォルト単位のemuで問題なかったです。
サイズを直接指定したいときなど、人間がわかりやすいように加工する必要があればpptx.util
をimport
してきて変換することも出来ます。
-
Cm
: cm単位 -
Inches
: インチ単位 -
Pt
: ポイント単位
from pptx.util import Cm, Inches, Pt
# emu→cmの場合
def emu_to_cm(left, top, width, height):
"""
emu→cmに変換 (1cm = 360000emu)
"""
return left/360000, top/360000, width/360000, height/360000
# cm→enumの場合
Cm(enum)
スライドの複製
Python-pptx: copy slide - Stack Overflowを参考に。
base_slide = prs.slides[base_slide_index]
duplicate_slide = prs.slides.add_slide(layout)
for shape in base_slide.shapes:
el = shape.element
new_el = deepcopy(el)
duplicate_slide.shapes._spTree.insert_element_before(new_el, "p:extLst")
スライドの削除
xml_slides = prs.slides._sldIdLst
slides = list(xml_slides)
xml_slides.remove(slides[slide_index])
スライドから特定のshape_type
の要素を削除
Is there a way to delete a shape with python-pptx - Stack Overflowを参考に。
shapes = prs.slides[slide_index].shapes
for shape in shapes:
if not shape.shape_type == MSO_SHAPE.RECTANGLE:
continue
XML_reference = shape._sp
XML_reference.getparent().remove(XML_reference)
PySimpleGUI
PySimpleGUIはtkinter(Python標準GUI)のラッパーで、tkinterと比べて直感的にGUIを作る事が出来ます。
PySimpleGUIも使い方は公式ドキュメントにだいたい載っています。
Call reference - PySimpleGUI
Cookbook - PySimpleGUI
基本的な使い方
Window
のlayout
にリストとしてコンポーネントを配置していくだけで簡単にGUIを作ることが出来ます。
とっても直感的です。
import PySimpleGUI as sg sg.theme('DarkAmber') # デザインテーマの設定 # ウィンドウに配置するコンポーネント layout = [ [sg.Text('ここは1行目')], [sg.Text('ここは2行目:適当に文字を入力してください'), sg.InputText()], [sg.Button('OK'), sg.Button('キャンセル')] ] # ウィンドウの生成 window = sg.Window('サンプルプログラム', layout) # イベントループ while True: event, values = window.read() if event == sg.WIN_CLOSED or event == 'キャンセル': break elif event == 'OK': print('あなたが入力した値: ', values[0]) window.close()
既にPySimpleGUIの基本的な使い方についての解説記事は多数あるので、あまり見かけなかった使い方だけ書いていきます。
基本的な使い方の参考になりそうな記事
- Pythonでも簡単にGUIは作れる - Qiita
- Tkinterを使うのであればPySimpleGUIを使ってみたらという話 - Qiita
- マイブームPySimpleGUIを紹介|eetann|note
要素の動的な更新(update
)
イベントをキャッチしてupdate
を呼び出します。
import PySimpleGUI as sg sg.theme('BluePurple') layout = [[sg.Text('Your typed chars appear here:'), sg.Text(size=(15,1), key='-OUTPUT-')], [sg.Input(key='-IN-')], [sg.Button('Show'), sg.Button('Exit')]] window = sg.Window('Pattern 2B', layout) while True: # Event Loop event, values = window.read() print(event, values) if event == sg.WIN_CLOSED or event == 'Exit': break if event == 'Show': # Update the "output" text element to be the value of "input" element window['-OUTPUT-'].update(values['-IN-']) window.close()
update
はキーワードを何も指定しない(上の例)とテキストを更新します。
特定の属性を指定すればその属性を更新することが出来ます。
コレを利用してイベントが発火したときに要素の有効/無効を切り替える、ユーザーの入力を元に要素を書き換える、といったことが可能です。
def disable(self, key):
"""要素を無効にする"""
window[key].update(disabled=True)
def enable(self, key):
"""要素を有効にする"""
window[key].update(disabled=False)
disabled
True
にすることで要素を使用不可にできます。
sg.FolderBrowse("フォルダ選択", disabled=True)
普通に要素を隠したい場合はvisible=False
で良いですが、操作して欲しくないけど隠したくもない箇所に対してはこちらを使います。
enable_events
True
にすることで通常イベントが発火しない要素でイベントを扱う事ができます。
sg.InputText("イベントの有効化", enable_events=True)
例 FolderBrowse
とInputText
enable_events
を使う場面として、InputText
によりフォルダパスの入力を受け取るというケースが挙げられます。
FolderBrowseというボタンを使うと、ユーザーが入力したフォルダパスをInputText
に受け取る事ができます。
FolderBrowse
自体は非常に便利なのですが、そのままでは「フォルダパスが入力された」というイベントを受け取る事ができません。
そこで、FolderBrowse
と紐付いているInputText
のenable_events
をTrue
にします。
下の例ではFolderBrowse
からの入力をInputText
が受け取ったときにイベント(スライド選択用コンボボックスの更新)が実行されています。
なお、InputText
の場合はユーザーが1文字でも入力したらイベントが発火するようになるので、入力が有効かチェックする、disabled=True
にしてFolderBrowse
からの入力以外を受け付けないようにする、などの対策が必要です。
その他の例
-
Combo
- コンボボックスの選択が更新されたときに発火
-
Radio
- ラジオボタンが選択されたときに発火
スタイルを辞書に切り出す
スタイルなどは、コンポーネントをインスタンス化する際に引数として渡すことで設定します。
小規模なGUIなら良いのですが、ある程度以上の規模になってくるとコンポーネントごとの設定が見にくく、修正も面倒になってきます。
それを解決するために辞書を使います。
Pythonでは**kwargs
のように**
をつけて引数を定義すると、その関数は任意の数のキーワード引数を受け取ることが出来るようになります。
関数呼び出し時に辞書オブジェクトに**
をつけて引数にすることで、key:value
のペアをキーワード:引数
のペアとして関数に渡せるのです。
def func_kwargs_positional(arg1, arg2, **kwargs): print('arg1: ', arg1) print('arg2: ', arg2) print('kwargs: ', kwargs) d = {'key1': 1, 'key2': 2, 'arg1': 100, 'arg2': 200} func_kwargs_positional(**d) # arg1: 100 # arg2: 200 # kwargs: {'key1': 1, 'key2': 2}
コレを利用して、size
, font
, disabled
などの設定を辞書に移す事ができます。
# イメージ
WINDOW_STYLES = {
"title": TITLE,
"size": WINDOW_SIZE,
"font": FONT,
"resizable": True,
}
window = sg.Window(layout=[layout], **WINDOW_STYLES)
このようにすることで、似たようなスタイルの場合使い回せる、スタイルの変更が容易になるなどのメリットがあります。
実装内容
PowerPointの情報を管理する
スライドが持つコンテンツの管理については、次のようなイメージで実装しました。
Slide
クラスがコンテンツを持ち、コンテンツがグループ分けされていて、各コンテンツがサイズ・座標・ラベルなどを持つ、というイメージです。
画像やテキストボックスについてはTypedDict
という通常の辞書よりも厳密に型を定義出来る型ヒントを使いました。
from typing import TypedDict
class SlideContent(TypedDict):
"""スライドコンテンツ
"""
coordinates: tuple[int, int]
size: tuple[int, int]
label: str
# total=False: 辞書初期化時などにキーが必須で無くなる
class Image(SlideContent):
"""画像
"""
path: str
class TextBox(SlideContent):
"""テキストボックス
"""
text: str
PowerPoint読み込み
前述のshape_type
判定とshape
のサイズ・座標取得を行うクラスを作りました。
class PresentationReader():
"""PowerPointの情報を読み取る。
"""
def __init__(self, path: str) -> None:
self.prs: Presentation = Presentation(path)
def get_image_templates(self, slide_index=0) -> list[Image]:
"""スライドが持つ画像の情報を取得"""
try:
shapes = self.prs.slides[slide_index].shapes
except IndexError:
raise IndexError("slide index out of range")
image_templates = []
for shape in shapes:
if shape.shape_type != MSO_SHAPE.RECTANGLE,:
continue
if not shape.text:
continue
image_templates.append(self.__set_content("image", shape))
return image_templates
# 省略...
出力用のスライド情報をセット
画像パスを元にラベル/データ名⇔画像パスの紐づけを行い、それを更にPowerPointからの入力(座標などの情報)と紐づけ、データ名/Image
/TextBox
等の情報を持つSlide
を生成することで、出力用の情報をひとまとめにする形にしました。
ここの実装はもう少し綺麗に出来た気がします。
テキストの置換
# 省略...
def set_textboxes_to_slides(self):
"""スライドに対してテキストボックスをセットする。
"""
for slide in self.slides:
template_contents = deepcopy(self.template_slide.contents)
current_label = 0
matched_pairs: dict[str, str] = {}
for template_group, group in zip(template_contents, slide.contents):
# group数が奇数の場合途中で終了し得るため
if not group:
return
for textbox in template_group["textbox"]:
text = textbox["text"]
# group_idを指す文字列が存在すれば置換する
text = re.sub('(@|@).*\d+', group["group_id"], text)
# labelを指す文字列が存在すれば置換する
if re.search('(#|#).*\d+', text):
# 既にマッチしたlabelを指す文字列が存在する場合それを利用する
if text in matched_pairs.keys():
text = matched_pairs[text]
# matched_pairsに存在しなければペアに追加して置換
else:
try:
replacement_label = group["image"][current_label]["label"]
except IndexError:
break
matched_pairs[text] = replacement_label
text = re.sub('(#|#).*\d+', replacement_label, text)
current_label += 1
textbox["text"] = text
group["textbox"] = template_group["textbox"]
-
template_slide.contents
(設定用PowerPointの情報)をコピー -
re
を使い、正規表現で置換対象の文字列を検索・置換- 「
@
や#
に続けて数字を入力してください」と伝えた場合、@1, @2, @_1, @ 1
など、半角/全角が入り混じったり、区切り文字を入れたり入れなかったりするパターンが考えられると思います。どのパターンにも対応出来るように正規表現を使いました。
- 「
PowerPoint書き込み
書き込みは次のような流れで実装しました。
- 設定用のPowerPointを複製
- 複製したPowerPointにアクセス
- ベースとなるスライドの置換対象図形・テキストボックスを削除
- ベースとなるスライドを必要な枚数複製
- 各スライドに画像・テキストボックスを出力
- ベースとなるスライドを削除
- 複製したPowerPointを保存
何故こんな面倒なことをしたかというと、「表を作ってその中に画像を入れる」ことが多い関係で、置換対象以外の図形は残しておきたいためです。
何故か表の中に画像を配置することが多いのです。見栄えが良いからでしょうか。
GUI
GUIは次のようになりました。
イベントの管理にはハンドラーを使いました。
といってもイベントをキーに、関数を値に持つ辞書を定義するだけです。
class Handler:
def __init__(self, controller: Controller) -> None:
self.controller = controller
self.functions = {
"-USER_UTIL_PWT-": self.controller.input_user_util_pwt,
"-SELECT_FOLDER-": self.controller.select_folder,
"-SELECT_FILES-": self.controller.select_files,
"-SRC_FOLDER-": self.controller.input_img_folder,
"-SRC_FILES-": self.controller.input_img_files,
"-SUBMIT-": self.controller.click_submit,
}
def handle(self, key, values):
if key not in self.functions:
return
event = self.functions[key]
event(values)
from src.Controllers.controller import Controller
from src.Controllers.handler import Handler
from src.Views.view import InterFace
def main():
interface = InterFace()
controller = Controller(interface.window)
handler = Handler(controller)
while True:
event, values = interface.window.read()
handler.handle(event, values)
if event is None:
interface.close_window()
break
if __name__ == "__main__":
main()
見ての通り、main.py
がかなりスッキリします。上級者っぽくてかっこいい
工夫点
使用までのハードルを下げる
現在所属している部署は非IT系、かつ高齢の方が多く、言うまでもなく効率化への壁が多くあります。
このような環境では、ツール使用へのハードルを極力下げることが必要になってきます。
自身の観測範囲内ではありますが、以下のようなツールが好まれる傾向にあるようです。
① PowerpointやExcel(=使い慣れている)で設定ができる
② インストール等の面倒な作業が不要、自分のPCにコピーすれば使える
①についてはPowerPointでフォーマットを指定できるようにして対処しました。
また、慣れているという点で、データ名とラベルの区切りには慣習的に使われている_
を採用しました。
②についてはPyinstallerによるexe化がメジャー(?)なようですが、生成されるファイルのサイズが大きすぎるし、動作も安定しないので個人的にあまり好みではありません。
それに、改修の度にexe化するのも面倒です。
- ユーザー視点でexe化と大差ない
- 改修が楽
上2つを満たす配布方法として、今回はembeddable pythonによる配布という選択肢を取りました。(Windows限定)
こちらの記事が詳しいですが、簡単に言うと最小限のPython実行環境をフォルダごと配布するようなイメージです。
ツールの実体であるmain.py
を起動するbat
ファイルも同じフォルダに入れておいて、bat
ファイルのショートカットを好きな場所に置いて使ってもらうようにしました。
embeddable python はデフォルトだとtkinter(及びそのラッパーであるPySimpleGUI)が使えないのでStack Overflowを参考にtkinterを使えるようにしました。
エラーをちゃんと通知する
前述の他部署が作った画像貼り付けツールは、「入力したフォルダに画像が無い」「結果出力先に設定されているサーバが停止している」などの理由で正常に動作しなかった場合に何も通知してくれませんでした。
問題があったときに何も通知されない苛立ち不便さは身に沁みていたので、不正な入力があった時は考えうる操作ミスを通知するようにしました。
型ヒントの使用・docstringの記述
今までのPython開発では、「どうせ自分しか見ないソースコードだから」と型定義やdocstringは特に書いていませんでした。
しかし、実際の開発現場で個人で作業するということはまずないと思います。
また、よく言われることですが過去の自分も他人のようなもので、「先週書いていたコードの意味がわからない」という場面には結構な割合で遭遇します。
そのため、意識付けも兼ねて型ヒントの使用やdocstringを書くようにしました。
実際に書いてみて、関数呼び出し側を書きやすい、エディタが勝手に型チェックして警告を出してくれるので間違いに気づきやすい、などのメリットを享受出来ました。やってよかったです。
改善点
テストを書いていない
- Pythonのテストフレームワークは扱ったことがない。Web開発学習中のため、新しいテストフレームワークの使い方を覚えたくない。
- 早く完成させたい。
という理由でテストを書かないことにしました。
この選択は開発中に何度も後悔しました。
テストを書かないことのデメリットは挙げるとキリがありませんが、例えば以下のような不便さを感じました。
- エラーがどこで起きているのか理解するまで時間がかかる
- 動作を確認するために軽いスクリプトを書く/GUIを操作する必要があり面倒
- 要件がハッキリしないため(設計の問題でもある)、不要なメソッドを実装してしまうことがあった
今から書くのはちょっと面倒なので書きませんが、今後は何があろうがテストを書こうと決心しました。
追加要望があった機能
- 画像のトリミング
- 貼り付け前に座標で一括トリミング、オブジェクトを認識して「良い感じ」にトリミングしてくれる、など。
- GUI内に説明を表示
- マニュアルを見に行くのが面倒なときやちょっと確認したいときなど、GUI内に説明が表示されていると嬉しいとの要望がありました。
- 説明用のポップアップと、それを呼び出すためのボタンでも付けようかと思います。
- Excelから画像を貼り付ける機能が欲しい
- 試験装置の中にはExcel形式で出力するものもあるので、Excelの画像を取り出す→PowerPointに貼り付けるという機能が欲しいそうです。
Excelそのまま使えよ
参考記事
python-pptx
- python-pptx — python-pptx 0.6.21 documentation
- 【Python×PowerPoint】python-pptxの導入~ファイル・スライド作成方法を徹底解説
- Python-pptx: copy slide - Stack Overflow
- Is there a way to delete a shape with python-pptx - Stack Overflow
PySimpleGUI
- Call reference - PySimpleGUI
- Cookbook - PySimpleGUI
-
PySimpleGUIでGUIアプリを作ってみた ~その1~
- ハンドラーでイベント管理する、スタイルを辞書に切り出すなどの考え方を参考にさせていただきました。
TypedDict
- [Python3.8~]ビルトインのTypedDictについて詳しく調べてみた(PEP589) - Qiita
-
[Python]PylanceのVS Code拡張機能をさっそく使ってみた。 - Qiita
- VSCodeのデフォルトだと型チェックが効かないことがあるのでこの記事を参考に設定を変更しました。
embeddable python
- 超軽量、超高速な配布用Python「embeddable python」 - Qiita
- 4. Windows で Python を使う — Python 3.10.4 ドキュメント
-
Python embeddable zip: install Tkinter - Stack Overflow
- tkinterの導入
その他
-
製造現場向けの自動化ツールをPythonで作る時に留意すること - Qiita
- ツールの作成・展開に当たっての留意点を参考にしました。製造現場ではありませんが、現場のレベル感は似たようなものなので。
- 動作サンプルに使用した画像