LoginSignup
75
70

More than 1 year has passed since last update.

Pythonでパワポ報告資料作成を効率化する(誰でも使えるように)

Last updated at Posted at 2022-05-10

はじめに

python-pptxとPySimpleGUIを使ってPowerPointで作ったフォーマット位置に画像を貼り付けるツールを作ったので紹介します。

開発の経緯

現職では主に製品の実証試験的な業務を担当しています。
業務の性質上、エビデンスとして画像を撮影 → PowerPoint(報告用)に貼り付ける という作業が多く発生します。

そういった作業が多くあるので既に効率化は図られていて、「枚数を指定すると一定間隔で画像を貼り付けてくれるツール」は存在します。(DXする部署が作成)

しかし、実際に報告資料を作成する際に欲しいのは決まったフォーマット・画像の配置であることが多いです。
そのため、一定間隔で貼り付けることが出来たとしてもそれをそのまま報告に使うことはほとんど出来ず、結局手作業で画像の位置を調整している人が多くいます。

そこで、上司に相談したり、職場の方に聞き込みをしたりして、

  1. フォーマットを自由に指定出来る
  2. PowerPointを使って(=誰でも使える)フォーマットを指定できる
  3. テキストをデータ名に置換出来たりすると嬉しい

という要素を持つツールがあれば良さそう、という結論に至ったので作りました。

だいたい3日で完成させました。

作ったもの

PowerPointで作成したフォーマットを使用して画像貼り付け・テキスト置換を行います。

PowerPointGenerator_特定レイアウト_デモ

主な機能

  • 1データに画像が複数存在する場合、指定したラベル位置に画像を貼り付けます。
    • 1データに対して複数のレイアウト(正面、背面、側面...)の画像が存在するイメージです。
  • 連番になっているデータ(1データ:1画像)の場合、指定した位置に対して順番に画像を貼り付けます。
  • 四角形 → 画像 への置換
    • 四角形の配置 = 画像の配置
    • 四角形のテキスト = 画像のラベル
    • 画像はデータ名とラベルを「 _ 」で区切る
  • テキストの置換
    • @1 (@ + 数字) = データ名へ置換
    • #1 (# + 数字) = ラベルへ置換

1データに複数画像が存在する場合(ラベル位置への貼付け)

1データに画像が複数存在する場合、ラベルの位置を指定することで画像を配置します。

入力フォーマット

ラベルを指定します。

  • 1枚のスライドに1グループの場合は「ラベル名」のみ指定します。
  • 1枚のスライドに複数グループが存在する場合、_ で「連番」と「ラベル名」を区切って指定します。
  • PowerPointで指定するラベル名と画像のラベル名は一致させる必要があります。

下の例では 2データ/1スライド なので、「1_右」, 「2_右」のように _ で「連番」と「ラベル名」を区切ります。
ラベリング_フォーマット

※ ラベル位置貼り付けの場合はテキスト置換を使う必要が無い(毎回ラベルが同じになるため)ので、置換ラベル(#〇〇)の順序がバラバラです。

入力画像

ねずみ_右の場合は「ねずみ」or「右」のどちらかをラベルとして指定します。
image

出力されるPowerPoint

1枚目
ラベリング 出力_1
2枚目
ラベリング 出力 2

1データに画像が1枚の場合(連番貼り付け)

連番になっているデータの場合、スライドを跨いで順番に画像を貼り付ける事ができます。

入力フォーマット

四角形の中に数字を指定します。
順に貼り付け_フォーマット

入力画像

fruits_1の場合は「fruits」がデータ名、「1」がラベル(連番)として扱われます。
image

出力されるPowerPoint

連番は0埋めの有無に関係なく、数値の大小で判定されます。

1枚目
順に貼り付け_出力 1
2枚目
順に貼り付け_出力 2

実装

動作環境

  • Windows10
    • Macでも動作するとは思いますが、一部機能(主にPySimpleGUI)が使えない、pathlib等のOSにより挙動が変わるライブラリにより影響が出る可能性があります。
    • Mac使ってる人にパワポ職人はいないと信じているので大丈夫でしょう
  • 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でもっと自由を

基本的な使い方

about-python-pptx
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でスライドを追加する時に使います。

side_layouts

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_TYPEMSO_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プロパティにアクセスします。
TextFrameparagraph(段落)を持ちます。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.utilimportしてきて変換することも出来ます。

  • 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

基本的な使い方

Windowlayoutにリストとしてコンポーネントを配置していくだけで簡単に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-demo

Pythonでも簡単にGUIは作れる - Qiita より

既にPySimpleGUIの基本的な使い方についての解説記事は多数あるので、あまり見かけなかった使い方だけ書いていきます。

基本的な使い方の参考になりそうな記事

要素の動的な更新(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()

demo-update

Cookbook - PySimpleGUIより

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)

disabled_toggle

普通に要素を隠したい場合はvisible=Falseで良いですが、操作して欲しくないけど隠したくもない箇所に対してはこちらを使います。

enable_events

Trueにすることで通常イベントが発火しない要素でイベントを扱う事ができます。

sg.InputText("イベントの有効化", enable_events=True)

FolderBrowseInputText

enable_eventsを使う場面として、InputTextによりフォルダパスの入力を受け取るというケースが挙げられます。

FolderBrowseというボタンを使うと、ユーザーが入力したフォルダパスをInputTextに受け取る事ができます。

FolderBrowse自体は非常に便利なのですが、そのままでは「フォルダパスが入力された」というイベントを受け取る事ができません。

そこで、FolderBrowseと紐付いているInputTextenable_eventsTrueにします。

下の例ではFolderBrowseからの入力をInputTextが受け取ったときにイベント(スライド選択用コンボボックスの更新)が実行されています。
input_event_eneble

なお、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}

Pythonの可変長引数(*args, **kwargs)の使い方 | note.nkmk.me

コレを利用して、size, font, disabledなどの設定を辞書に移す事ができます。

# イメージ

WINDOW_STYLES = {
    "title": TITLE,
    "size": WINDOW_SIZE,
    "font": FONT,
    "resizable": True,
}

window = sg.Window(layout=[layout], **WINDOW_STYLES)

このようにすることで、似たようなスタイルの場合使い回せる、スタイルの変更が容易になるなどのメリットがあります。

実装内容

PowerPointの情報を管理する

スライドが持つコンテンツの管理については、次のようなイメージで実装しました。

image_2グループ

Slideクラスがコンテンツを持ち、コンテンツがグループ分けされていて、各コンテンツがサイズ・座標・ラベルなどを持つ、というイメージです。

画像やテキストボックスについてはTypedDictという通常の辞書よりも厳密に型を定義出来る型ヒントを使いました。

content.py
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のサイズ・座標取得を行うクラスを作りました。

presentation.py
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を生成することで、出力用の情報をひとまとめにする形にしました。

ここの実装はもう少し綺麗に出来た気がします。

テキストの置換

slide.py
    # 省略...
    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書き込み

書き込みは次のような流れで実装しました。

  1. 設定用のPowerPointを複製
  2. 複製したPowerPointにアクセス
  3. ベースとなるスライドの置換対象図形・テキストボックスを削除
  4. ベースとなるスライドを必要な枚数複製
  5. 各スライドに画像・テキストボックスを出力
  6. ベースとなるスライドを削除
  7. 複製したPowerPointを保存

何故こんな面倒なことをしたかというと、「表を作ってその中に画像を入れる」ことが多い関係で、置換対象以外の図形は残しておきたいためです。
何故か表の中に画像を配置することが多いのです。見栄えが良いからでしょうか。

GUI

GUIは次のようになりました。

GUI-overview

イベントの管理にはハンドラーを使いました。

といってもイベントをキーに、関数を値に持つ辞書を定義するだけです。

handler.py
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)
main.py
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

PySimpleGUI

TypedDict

embeddable python

その他

75
70
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
75
70