python-pptxでレポーティングを自動化する

VISITS Technologies Advent Calendar 2019 18日目は@woods0918が担当します。
前日の@kshibata101の記事[AWSしか触ったことのないサーバーエンジニアがGCPで本番運用してみた(後編)](https://qiita.com/kshibata101/items/56bf3714c4828dd0e68e)が読みごたえのある良記事だったので、是非ご覧ください。

本記事では、「データ分析の結果を定期的にPowerpointでレポーティングしてくれ」という~~面倒な~~依頼を快く引受けるために必須の[python-pptx](https://python-pptx.readthedocs.io/en/latest/#)を紹介できればと思います。

## python-pptxで何をするか
分析した結果やそこから得られる示唆を自動でPowerpointのレポートにまとめることができます。
例えば、バッチで動いている日次分析処理の最後に、python-pptxによるレポート作成処理とIncoming Webhooks（slack）を使った配信処理を追加すれば、毎日自動的に報告者のもとへレポートが届くようになります。

## インストール方法
```
pip install python-pptx
```

## 必要な機能をImport
``` py
from pptx import Presentation
from pptx.util import Inches
from pptx.chart.data import ChartData, CategoryChartData, XyChartData, BubbleChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION
from pptx.enum.text import PP_ALIGN
from pptx.util import Cm, Pt
from config.setting import _EMUS_PER_CM
```

## pptxファイルの読み込み
新規でPowerpointファイルを作成する場合は、

``` py
prs = Presentation()
```

既存のファイルを読み込む場合には、

``` py
prs = Presentation(filepath)
```

## スライドレイアウトの読み込み
prs.slide_layoutsはスライドレイアウトをリスト形式で保持している。スライドレウアウトとは以下の画像のこと。

``` py
layout = prs.slide_layouts[i]
```
<img width="494" alt="スクリーンショット 2019-12-17 22.54.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270069/ca4b9752-6eec-7c29-54c3-89180b334f3a.png">

## スライドの追加
``` py
slide = prs.slides.add_slide(layout)
```

## タイトルの設定

``` py
def add_title(slide, msg):
    """
    args
        slide[slide]: Slide object
        msg[str] : Slide title message
    return:
        None
    """
    shapes = slide.shapes
    shapes.title.text = msg
```

## テキストボックスの生成
以下に記述した設定以外にも、テキストの色など変えることができるので、是非公式ドキュメントを参照してみてください。

``` py
def add_text(slide, msg, left, top, width, height, font_size, is_bold):
    """
    args:
        slide[slide]: Slide object
        msg[str]: Text box message
        left[int]: Position from the left end
        top[int] : Position from top
        width[int]: Width of object
        height[int]: Height of object
        font_size[int]: Font size
        is_bold[int]: Whether to make the text bold
    return:
        None
    """
    textbox = slide.shapes.add_textbox(Cm(left), Cm(top), Cm(width), Cm(height))
    p = textbox.textframe.add_paragraph()
    p.text = msg
    p.font.size = Pt(font_size)
    p.font.bold = is_bold
```

## テーブルの挿入
おそらくデータを扱う方だとpandas.DataFrameを使うことが多いと思うので、DataFrameをPowerpointにテーブルオブジェクトとして挿入する関数を定義します。

``` py
def add_table(slide, df, left, top, width, height, font_size):
    '''
    args:
        slide[slide]: Slide object
        df[DataFrame] : Display data
        left[int]: Position from the left end
        top[int] : Position from top
        width[int]: Width of object
        height[int]: Height of object
        font_size[int]: Font size
    return:
        None
    '''

    column_names = df.columns.tolist()
    index_names = df.index.tolist()
    col_num = len(column_names) + 1
    row_num = len(index_names) + 1

    table = shapes.add_table(
         row_num, 
         col_num, 
         Cm(left), 
         Cm(top), 
         Cm(width), 
         Cm(height)
    ).table
    
    # Columnの値を挿入 
    for i in range(col_num):
        table.columns[i].width = Cm(width/col_num)
        if i > 0:
            table.cell(0, i).text = column_names[i-1]
            table.cell(0, i).text_frame.paragraphs[0].font.size = Pt(font_size)
            # 中央揃え
            table.cell(0, i).text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
    
    # Indexの値を挿入
    for i in range(row_num):
        table.rows[i].height = Cm(height/row_num)
        if i > 0:
            table.cell(i, 0).text = index_names[i-1]
            table.cell(i, 0).text_frame.paragraphs[0].font.size = Pt(font_size)
            # 中央揃え
            table.cell(i, 0).text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
    
    # セルの値を挿入
    for i in range(1, row_num):
        for j in range(1, col_num):
            table.cell(i, j).text = str(df.iloc[i-1, j-1])
            table.cell(i, j).text_frame.paragraphs[0].font.size = Pt(font_size)
            # 中央揃え
            table.cell(i, j).text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
```

## 画像の挿入
```  py
def add_img(slide, img_path, left, top, width, height):
    """
    args:
        slide[slide]: Slide object
        img_path[str] : Image file path
        left[int]: Position from the left end
        top[int] : Position from top
        width[int]: Width of object
        height[int]: Height of object 
    return:
        None       
    """
    pic = slide.shapes.add_picture(img_path, 0, 0)
    pic.width = Cm(width)
    pic.height = Cm(height)
    pic.left = Cm(left)
    pic.top = Cm(top)
```

## グラフの挿入
### 棒グラフ
``` py
def bar(slide, categories, values, left, top, width, height, chart_title, x_label, y_label):
    """
    args:
        slide[slide]: Slide object
        categories[List[str]] : X-axis values
        values[Dict[str, List[float]]] : Key=Dataset name, Value=Dataset
        left[int]: Position from the left end
        top[int] : Position from top
        width[int]: Width of object
        height[int]: Height of object 
        chart_title[str] : Chart title
        x_label[str] : X-label title
        y_label[str] : Y-label title
    return:
        None
    """
    chart_data = CategoryChartData()
    chart_data.categories = categories

    for key, value in values.items():
        chart_data.add_series(key, value)
    
    chart = slide.shapes.add_chart( 
        XL_CHART_TYPE.COLUMN_CLUSTERED, 
        Cm(left), 
        Cm(top), 
        Cm(width), 
        Cm(height), 
        chart_data
    ).chart

    chart.font.size = Pt(10)

    if len(values.keys()) > 1:
        chart.has_legend = True
        chart.legend.position = XL_LEGEND_POSITION.RIGHT
        chart.legend.include_in_layout = False
    
    # グラフタイトルの設定
    chart.has_title = True
    chart.chart_title.has_text_frame = True
    chart.chart_title.text_frame.text = chart_title
    chart.chart_title.text_frame.paragraphs[0].font.size = Pt(12)
    value_axis = chart.value_axis
    category_axis = chart.category_axis
　　
    # X軸ラベルの設定
    value_axis.has_title = True
    value_axis.axis_title.has_text_frame = True
    value_axis.axis_title.text_frame.text = y_label
    value_axis.axis_title.text_frame.paragraphs[0].font.size = Pt(10)

    # Y軸ラベルの設定
    category_axis.has_title = True
    category_axis.axis_title.has_text_frame = True
    category_axis.axis_title.text_frame.text = x_label
    category_axis.axis_title.text_frame.paragraphs[0].font.size = Pt(10)

    # データラベルの設定
    plot = chart.plots[0]
    plot.has_data_labels = True
    data_labels = plot.data_labels
    data_labels.font.size = Pt(10)
    data_labels.position = XL_LABEL_POSITION.INSIDE_END
```

### 散布図
```py
def scatter(slide, data, left, top, width, height, chart_title, x_label, y_label):
    """
    args:
        slide[slide]: Slide object
        data[Dict[str, List[List[float]]]] : Key=Dataset name, Value=Dataset
        left[int]: Position from the left end
        top[int] : Position from top
        width[int]: Width of object
        height[int]: Height of object 
        chart_title[str] : Chart title
        x_label[str] : X-label title
        y_label[str] : Y-label title
    return:
        None
    """
    chart_data = XyChartData()
    
    for key, values in data.items():
        series = chart_data.add_series(key)
        for i in range(len(values[0])):
            series.add_data_point(values[0][i], values[1][i])

    chart = slide.shapes.add_chart(
        XL_CHART_TYPE.XY_SCATTER, 
        Cm(left), 
        Cm(top), 
        Cm(width), 
        Cm(height), 
        chart_data
    ).chart

    chart.font.size = Pt(8)

    # データラベルの設定
    for i, (key, values) in enumerate(data.items()):
        for j, value in enumerate(values[2]):
            chart.series[i].points[j].data_label.has_text_frame = True
            chart.series[i].points[j].data_label.text_frame.text = value
            chart.series[i].points[j].data_label.text_frame.paragraphs[0].font.size = Pt(6)
    
    # Legendの設定
    chart.has_legend = True
    chart.legend.position = XL_LEGEND_POSITION.RIGHT
    chart.legend.include_in_layout = False
    
    # グラフタイトルの設定
    chart.has_title = True
    chart.chart_title.has_text_frame = True
    chart.chart_title.text_frame.text = chart_title
    chart.chart_title.text_frame.paragraphs[0].font.size = Pt(12)

    # X軸ラベルの設定
    category_axis = chart.category_axis
    category_axis.has_title = True
    category_axis.axis_title.has_text_frame = True
    category_axis.axis_title.text_frame.text = x_label
    category_axis.axis_title.text_frame.paragraphs[0].font.size = Pt(10)

    # Y軸ラベルの設定
    value_axis = chart.value_axis
    value_axis.has_title = True
    value_axis.axis_title.has_text_frame = True
    value_axis.axis_title.text_frame.text = y_label
    value_axis.axis_title.text_frame.paragraphs[0].font.size = Pt(10)

```

### 円グラフ
``` py
def pie(slide, categories, values, left, top, width, height, chart_title):
    """
    args:
        slide[slide]: Slide object
        categories[List[str]] : The name of each data
        values[List[float]] : Dataset
        left[int]: Position from the left end
        top[int] : Position from top
        width[int]: Width of object
        height[int]: Height of object 
        chart_title[str] : Chart title
    return:
        None
    """

    chart_data = ChartData()
    chart_data.categories = categories

    chart_data.add_series("Pie", values)
    
    chart = slide.shapes.add_chart(
        XL_CHART_TYPE.PIE, 
        Cm(left), 
        Cm(top), 
        Cm(width), 
        Cm(height), 
        chart_data
    ).chart

    chart.font.size = Pt(8)

    chart.has_legend = True
    chart.legend.position = XL_LEGEND_POSITION.RIGHT
    chart.legend.include_in_layout = False
    
    # グラフタイトルの設定
    chart.has_title = True
    chart.chart_title.has_text_frame = True
    chart.chart_title.text_frame.text = chart_title
    chart.chart_title.text_frame.paragraphs[0].font.size = Pt(11)

    # データラベルの設定
    chart.plots[0].has_data_labels = True
    data_labels = chart.plots[0].data_labels
    if is_percentile:
        data_labels.number_format = '0%'
    data_labels.position = XL_LABEL_POSITION.INSIDE_END
```

### その他
上記のグラフ以外にも、バブルチャートや折れ線グラフを挿入することもできます。
バブルチャートであれば散布図を参考に、折れ線グラフであれば棒グラフを参考にしていただくと、簡単に実装できるかと思いますので、是非チャレンジしてみてください。

## スライドの保存
``` py
prs.save(filepath)
```

## まとめ
JupyterやGoogle Colaboratoryでレポートを共有できると楽なのですが、やはりビジネスにおける資料作成のデファクトであるPowerpointでのレポートを求められる機会はどうしても発生し得るかと思います。
そのような時でも涼しい顔で対応できるように、是非python-pptxを学んでみてはいかがでしょうか。

内容に誤りや不明点があればコメントいただけると幸いです🙇‍♂️