Python の PDF 作成ライブラリ fpdf2 で見積書を作ってみた

:::note warn
以下段階の人が書いています
1. ドキュメントをパッと見た (done)
1. ドキュメント読みながら手を動かしてみる (←今ここ)
1. 運用レベルで使ってみる (まだ)
:::

# fpdf2 とは

依存が Python のみのライブラリで、  this project is mature and actively maintained. と述べられています。

https://github.com/PyFPDF/fpdf2


Python には [reportlab](https://pypi.org/project/reportlab/) という実績のある pdf 作成ライブラリ があります。一方で、 PyPDF2 のメンテナーの方が以下のような紹介文を書かれていたのが気になって触ってみました。

> edit: As the maintainer of pypdf and PyPDF2, I will work more closely with fpdf2 in future. They seem to have a good community and the project seems well-managed!

https://martinthoma.medium.com/the-python-pdf-ecosystem-in-2023-819141977442


# 作ってみた見積書と書いたコード

以下のような見積書を作成しました。

![スクリーンショット 2023-07-03 8.57.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110482/47bc0f01-9a43-382d-d4d3-005568d0fa18.png)


:::note warn
NotoSansJP の install が必要です。
NotoSansJP へのパスを指定する必要があります。
:::


``` Python3
import datetime
import math

from fpdf import FPDF
from fpdf.fonts import FontFace

tax_rate = 0.1


class PDF(FPDF):
    def 見積もり日(self):  # noqa
        base_x = -70
        base_y = 10
        self.set_xy(base_x, base_y)
        self.cell(txt=f"見積もり日 : {format(datetime.date.today(), '%Y年%m月%d日')}")

    def 見積もり番号(self):  # noqa
        base_x = -70
        base_y = 15
        self.set_xy(base_x, base_y)
        self.cell(txt=f"見積もり番号 : {format(datetime.date.today(), '%Y%m%d')}-001")

    def 見積書タイトル(self):  # noqa
        base_x = 95
        base_y = 30
        self.set_font(size=20)
        self.set_xy(95, 30)
        self.set_xy(base_x, base_y)
        self.cell(txt="見積書", align="C")

    def 顧客宛名(self, company_name: str):  # noqa
        base_x = 10
        base_y = 50
        self.set_xy(base_x, base_y)
        atena = f"{company_name}　御中"
        cell_width = atena.__len__() * 4
        cell_height = 20
        self.set_draw_color(0, 0, 0)
        self.set_line_width(0.3)
        self.cell(cell_width, cell_height, txt=atena)
        self.line(
            self.get_x() - cell_width,
            self.get_y() + cell_height - 5,
            self.get_x(),
            self.get_y() + cell_height - 5,
        )

    def 見積もり件名(self, title: str):  # noqa
        base_x = 10
        base_y = 70
        self.set_xy(base_x, base_y)
        self.cell(txt="件名：" + title)

    def 見積もり期限(self, interbal):  # noqa
        base_x = 10
        base_y = 75
        self.set_xy(base_x, base_y)
        self.cell(
            txt=f"有効期限 : {format(datetime.date.today() + datetime.timedelta(days=interbal), '%Y年%m月%d日')}"
        )

    def 見積もり金額(self, price):  # noqa
        base_x = 10
        base_y = 100
        self.set_xy(base_x, base_y)
        price_str = f"お見積もり金額   ¥{price:,}"  # price を 3桁区切りにする
        cell_width = price_str.__len__() * 3
        cell_height = 20
        self.set_draw_color(0, 0, 0)
        self.set_line_width(0.3)
        self.cell(cell_width, cell_height, txt=price_str)
        self.line(
            self.get_x() - cell_width,
            self.get_y() + cell_height - 5,
            self.get_x(),
            self.get_y() + cell_height - 5,
        )

    def 見積もり会社(
        self, my_company_name: str, company_adress: str, account_user_name: str
    ):  # noqa
        base_x = 150
        base_y = 60
        self.set_xy(base_x, base_y)
        self.cell(txt=my_company_name)
        self.set_xy(base_x, base_y + 5)
        self.cell(txt=company_adress)
        self.set_xy(base_x, base_y + 10)
        self.cell(txt=account_user_name)

    def 見積もり明細(self, data: list[dict]) -> int:  # noqa
        if len(data) == 0:
            return 0
        base_x = 10
        base_y = 150
        self.set_xy(base_x, base_y)
        self.set_draw_color(255, 0, 0)
        self.set_line_width(0.3)
        headings_style = FontFace(color=255, fill_color=(255, 100, 0))
        with pdf.table(
            borders_layout="NO_HORIZONTAL_LINES",
            cell_fill_color=(224, 235, 255),
            col_widths=(42, 39, 35, 42),
            cell_fill_mode="ROWS",
            headings_style=headings_style,
            line_height=6,
            text_align=("LEFT", "CENTER", "RIGHT", "RIGHT"),
            width=160,
        ) as table:
            sub_total_amount = 0
            for page, data_row in enumerate(data):
                if page == 0:
                    row = table.row()
                    row.cell("項目")
                    row.cell("数量")
                    row.cell("単価")
                    row.cell("金額")
                row = table.row()
                row.cell(data_row["項目"])
                row.cell(data_row["数量"])
                row.cell(f"¥{int(data_row['単価']):,}")
                row.cell(f"¥{int(data_row['金額']):,}")
                sub_total_amount += int(data_row["金額"])
            row = table.row()
            row.cell("小計")
            row.cell("")
            row.cell("")
            row.cell(f"¥{sub_total_amount:,}")

            row = table.row()
            row.cell("消費税")
            row.cell("")
            row.cell("")
            row.cell(f"¥{int(sub_total_amount * tax_rate):,}")

            row = table.row()
            row.cell("合計")
            row.cell("")
            row.cell("")
            total_amount = sub_total_amount + (sub_total_amount * tax_rate)
            row.cell(f"¥{int(total_amount):,}")
            # total_amount を切り上げ
            return math.ceil(total_amount)


customer_company_name = "梨木製作所"
my_company_name = "Hoge Inc."
quotations_title = "各種加工見積もりの件"
quotation_items = [
    {"項目": "アイテム1", "数量": "1", "単価": "1000", "金額": "1000"},
    {"項目": "アイテム2", "数量": "2", "単価": "10000", "金額": "20000"},
    {"項目": "アイテム3", "数量": "10", "単価": "1000", "金額": "10000"},
]
account_user_name = "梨木太郎"
company_address = "東京都渋谷区渋谷1-1-1"
font_family = "NotoSansJP"
font_path = "<path_to_font>"

pdf = PDF()
pdf.add_font(font_family, fname=font_path)
pdf.set_font(font_family, size=10)

pdf.set_title(f"{customer_company_name}様　お見積もり書")
pdf.set_author(my_company_name)

pdf.add_page()
pdf.見積もり日()
pdf.見積もり番号()
pdf.見積書タイトル()
pdf.set_font(font_family, size=10)
pdf.顧客宛名(customer_company_name)
pdf.見積もり件名(quotations_title)
pdf.見積もり期限(30)
pdf.見積もり会社(my_company_name, company_address, account_user_name)
total_amount = pdf.見積もり明細(data=quotation_items)
pdf.見積もり金額(total_amount)

pdf.output("quotation.pdf")

```


# 実装する上でのポイント

cell は描画した後に、デフォルトで移動するのがポイントです。

https://pyfpdf.github.io/fpdf2/fpdf/fpdf.html#fpdf.fpdf.FPDF.cell

![スクリーンショット 2023-07-03 8.44.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110482/37d6a3de-41e3-32c6-03d8-f65319f7987e.png)

文字の下にアンダーラインを作る場合は以下のように実装しました。
(markdown style でアンダーラインを作る方法もあるみたいですが、あえて line メソッドを使っています。)

![スクリーンショット 2023-07-03 8.46.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110482/98ff1cda-5921-0ce1-b26a-c0d7e87ef729.png)

``` python3
self.cell(cell_width, cell_height, txt=price_str)
        self.line(
            self.get_x() - cell_width,  # cell描画前に戻る
            self.get_y() + cell_height - 5,  # -5 は適当な調整です
            self.get_x(),
            self.get_y() + cell_height - 5,  # -5 は適当な調整です
        )
```

以上。