🗒️

Python と LaTeX でビジネス文書ジェネレータを作ってみる

2022/06/21に公開

Microsoft Word のテンプレートを作成しておき, 年月日や一部内容を毎度書き直して印刷やPDF出力して利用している文書などは有りませんか?
弊社では, 設備のメンテナンス告知文などでこのような Word の使い方をよく目にしており, システマチック(というかなんちゃって差し込み印刷)にして省力化してみたいと思いました.

使うもの

  • Python
    • pip - Pythonのパッケージマネージャ
    • Flask - 軽量なサーバサイドフレームワーク
    • Jinja2 - テンプレートエンジン
  • LaTeX - 古くからある組版システム. 論文執筆などで活躍.

やってみる

LaTeX インストール

# MacOS
brew install texlive
# Ubuntu
apt -y install texlive texlive-lang-japanese

LaTeX テンプレートの作成

既存Word文書を LaTeX で書き直すか, 新たに作成しましょう.
ここでは LaTeX の文法などは説明しません. 枯れているので, 第三者によるものも含めドキュメントはそこそこ豊富です.
TeX Wiki LaTeX入門 を読めば基礎はある程度理解できると思います.

./maintenance_poster.tex
\documentclass[paper=a4,dvipdfmx,12pt]{jsarticle}
\usepackage[top=30truemm,bottom=30truemm,left=25truemm,right=25truemm]{geometry}

% メタデータ関連
\usepackage{hyperref}
\usepackage{pxjahyper}
\hypersetup {
  pdfauthor={桃太郎一行}, % 発行者名か会社名や団体名など
  pdfcreator={桃太郎一行}, % 会社名や団体名など
  pdfproducer=, % 空にしておくと項目は消えず空欄になる
  % pdfsubject=,
  pdftitle={鬼退治メンテナンスのお知らせ}, % タイトル
}

\usepackage{setspace}

\renewcommand{\abstractname}{\textless \space\space \textgreater}
\pagestyle{empty}
\begin{document}
  \begin{flushright}
    {\large \和\today}
  \end{flushright}
  \begin{flushleft}
    {\large お客様各位} % 宛先
  \end{flushleft}
  \begin{flushright}
    {\large 桃太郎一行} % 会社名や団体名など
  \end{flushright}
  \part*{}
  \begin{center}
    \begin{spacing}{1}
      {\LARGE 鬼退治のお知らせ} % タイトル
    \end{spacing}
  \end{center}

  % \section*{}
  平素は鬼退治サービスをご利用いただき、誠にありがとうございます。
  下記の日程におきまして、鬼退治メンテナンスを実施いたします。
  期間中、ご利用のお客様には大変ご迷惑をおかけいたしますが、ご理解とご協
  力の程お願い申し上げます。
  また、作業状況により作業時間が前後する場合がございます。恐れ入りますが
  予めご了承くださいますようお願い申し上げます。

  \subsection*{}
  \begin{abstract}
    \noindent
    \begin{description}
      \item[\rm 作業日程:] 2022年1月23日 ~ 2022年2月28日
      \item[\rm 作業時間:] 1時1分 ~ 12時34分
      \item[]
      \item[\rm 作業内容:] 鬼退治メンテナンス
      \item[\rm 作業場所:] 鬼ヶ島 鬼居住エリア
      \item[\rm 影響内容:] 鬼ヶ島が約10時間立ち入り禁止になります
      \item[\rm その他:] 昔々あるところに、おじいさんとおばあさんが住んでいました。おじいさんは山へ芝刈りに、おばあさんは川へ洗濯に行きました。これはダミー文章です。
    \end{description}
  \end{abstract}

  \section*{}
  なお、補足事項が云々〜。
  おばあさんが川で洗濯をしていると、ドンブラコ、ドンブラコと、大きな桃が流れてきました。これはダミー文章です。
  ご迷惑をおかけし大変申し訳ありませんが、ご理解ご協力のほどよろしくお願い申し上げます。

  \begin{flushright}
    以上
  \end{flushright}
\end{document}

PDFを生成してみる

ptex2pdf -l maintenance_poster.tex
# 同じディレクトリに maintenance_poster.pdf が作成されます
# Tip: MacOS であれば open ./maintenance_poster.pdf とコマンドを打つと直接ファイルを開けます

下図の様なファイルが出力されます
出力されたPDF
それっぽくできてますね!(ビジネス文書のマナーに照らすと改善の余地はありそうですが, 自動生成にしては良くできてるんじゃないでしょうか!)

この時点で, Word のような冗長なGUI操作をすることなく, 高速に文書を生成できるようになりました! 🎉

コマンドで文書生成できるようになって十分満足したら, ここで離脱しても大丈夫です.
以降は, Webブラウザから文書を生成/印刷できるようにしていきます.

.tex ファイルを Jinja2 テンプレート化

詳細は省きますが, Jinja2 では {{ 展開する変数名 }} の様なタグでテンプレートファイルに文字列等を流し込むことができます. これを利用すれば, 文書を発行するたびに変更しなければならない部分を自動で流し込めるようになります.

先程のファイルの可変にしたい箇所を下記の様に変更します.

./maintenance_poster.tex
@@ -45,8 +45,8 @@
   \begin{abstract}
     \noindent
     \begin{description}
-      \item[\rm 作業日程:] 2022年1月23日 ~ 2022年2月28日
-      \item[\rm 作業時間:] 1時1分 ~ 12時34分
+      \item[\rm 作業日程:] {{start_date}}{{end_date}}
+      \item[\rm 作業時間:] {{start_time}}{{end_time}}
       \item[]
       \item[\rm 作業内容:] 鬼退治メンテナンス
       \item[\rm 作業場所:] 鬼ヶ島 鬼居住エリア

Flask で簡単な WebUI を作る

前提条件

  • Python 3系がインストールされている
    • pip が使用できる(上記のPython実行環境に紐づく前提)

準備

pip install flask jinja2

Flask のコードを書く

メインの最小構成の Python コードです.
本来は Blueprint を使ったり, 構成も綺麗にしたいところです.

./app.py
import os
import subprocess
import uuid
from datetime import datetime
from jinja2 import Environment, FileSystemLoader

from flask import (Flask, flash, render_template, request, send_from_directory)
app = Flask(__name__)

OUT_DIR = '/tmp/'

@app.route('/', methods=('GET', 'POST'))
def index():
    if request.method == 'POST':
        try:
            start_datetime = datetime.strptime(
                f'{request.form.get("start_date")} {request.form.get("start_at")}', '%Y-%m-%d %H:%M'
            )
            end_datetime = datetime.strptime(
                f'{request.form.get("end_date")} {request.form.get("end_at")}', '%Y-%m-%d %H:%M'
            )
            
            file_path = generate_poster(start_datetime, end_datetime)

            return send_from_directory(OUT_DIR, os.path.basename(
                file_path), as_attachment=False)
        except Exception as e:
            flash(
                f'pdfの出力に失敗しました。\n{e}',
                category='alert-danger'
            )

    return render_template('index.html')

def generate_poster(start_datetime, end_datetime):
    """
    メンテナンスのお知らせを生成します
    """
    template_dir = './'


    # NOTE: Jinja2 のデリミタを変更して LaTeX の構文と衝突するのを防ぐ場合.
    #       ptex2pdf を手動実行しての見栄え確認や, エディタのLaTeXプラグインでプレビュー表示する場合等に有効です.
    #       この場合は, Jinja2テンプレート上の表記は `[[ variable_name ]]` になります.
    # env = Environment(
    #     loader=FileSystemLoader(template_dir, encoding='utf8'),
    #     variable_start_string='[[',
    #     variable_end_string=']]',
    # )
    env = Environment(loader=FileSystemLoader(template_dir, encoding='utf8'))
    template = env.get_template('maintenance_poster.tex')

    file_id = str(uuid.uuid4())
    tex_file_path = os.path.join(OUT_DIR, f'{file_id}.tex')
    pdf_file_path = os.path.join(OUT_DIR, f'{file_id}.pdf')

    with open(tex_file_path, 'w') as f:
        f.write(template.render(
            start_date=start_datetime.strftime('%Y年%-m月%-d日'),
            end_date=end_datetime.strftime('%Y年%-m月%-d日'),
            start_time=start_datetime.strftime('%-H時%-M分'),
            end_time=end_datetime.strftime('%-H時%-M分'),
        ))

    try:
        cmd = f'ptex2pdf -l {os.path.join(OUT_DIR, file_id)}'
        subprocess.check_call(cmd.split(), cwd=OUT_DIR)
    except subprocess.CalledProcessError:
        raise Exception('ptex2pdf の実行中にエラーが発生しました。')
    return pdf_file_path

Bootstrap を交えつつ簡単な画面を作成します.
HTML的に突っ込みどころがあるかもしれませんが, サンプル故にお許しください.

./templates/index.html
<title>告知文印刷</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">

{% block content %}
<div class='container'>
    <h1 class="h1">メンテナンス告知文印刷</h1>
</div>

<div id='app' class='container'>
    <form method="post">
        <div class="mb-3 row" style="margin: 10px 0px;">
        </div>
        <div class="col-sm-2">
            <label for="start_date">作業開始日</label>
            <input type="date" name="start_date" id="start_date" class="form-control" required>
        </div>
        <div class="col-sm-2">
            <label for="start_at">作業開始時刻</label>
            <input name="start_at" id="start_at" class="form-control" type="time" required>
        </div>
        <div class="col-auto" style="display: flex; align-items: center;">
            <p></p>
        </div>
        <div class="col-sm-2">
            <label for="end_date">作業終了日</label>
            <input type="date" name="end_date" id="end_date" class="form-control" required>
        </div>
        <div class="col-sm-2">
            <label for="end_at">作業終了時刻</label>
            <input name="end_at" id="end_at" class="form-control" type="time" required>
        </div>
        <div class="mb-3 row" style="margin: 10px 0px;">
            <div class="col-auto" style="margin: 10px 0px;">
                <input name="print" id="submit" type="submit" class="btn btn-primary col-auto" value="印刷ダイアログを開く"
                    style="margin-top: 10px;margin-bottom: 10px;">
            </div>
        </div>
    </form>
</div>
{% endblock %}

起動してみる

下記コマンドを実行し, 開発用サーバを立ち上げます.
(デプロイして使う場合は本番用のサーバを立ち上げないといけません. 本稿では触れません.)

export FLASK_APP=app
flask run

起動すると下記ログが出ます.

$ flask run
 * Serving Flask app 'app' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000 (Press CTRL+C to quit)

ブラウザで http://127.0.0.1:5000 を開くと画面が表示されます 🎉
Web画面

フォームに日時を入力してボタンをクリックすると, 下図のように印刷ダイアログが表示され, そのまま印刷したりPDFをダウンロードすることができます. 🎉
(これは Chrome の場合です. ブラウザにより挙動が異なる場合があります.)
印刷ダイアログ

以上でWebフォーム化ができました. 🎉🎉

最後に

Flask 周りはかなり最低限の実装で, 初心者の方には難しくなってしまったかもしれませんが, 少しでも文書出力の自動化の参考にしていただけたら嬉しいです.

Discussion