DjangoでReportLabを使って改ページやページ番号を付加する方法

Django

はじめに

PythonとDjangoで使ってPDFを作成するためにReportLabを使う方法を紹介します。

サンプルでは物品データを保存したデータベースから取り出し、整形してA4用紙のPDFを作成します。
データには「備考」というデータサイズが可変になる項目があったため、改ページの条件出しを行いました。

その他の工夫は以下の通りです。

・ヘッダ部は全ページに印刷
・ページ数を付加
・文字化け回避のためにフォントを埋め込み
・ボタンを押したとき(submit)、Webブラウザに自動でダウンロード(表示)させる。

環境

- Debian buster 64bit
- Python 3.9.5
- Django 3.2.3
- ReportLab 3.5.67
- IPAexフォント(ipaexg.ttf) Ver.004.01

仕様

ボタンを押すとPDFを作成してWebブラウザにダウンロードさせます。
データベースから必要なデータを検索してA4用紙(ページ数も付加)に印刷します。

作成したPDF

説明

「build」関数から「header_footer」関数に変数を渡す

build関数でcustom_data変数を指定することで、「header_footer」関数で場合分けができます。
ヘッダ内容をデータによって場合分けできます。
この機能を使用する場合は「from functools import partial」の宣言が必要です。
stackoverflowの記事

build関数

「onFirstPage」は1ページ目を処理します。
「onLaterPages」は2ページ目以降を処理します。
「canvasmaker」はページ番号を付加する関数などを指定します。

一部のフォントの大きさを変更する

一部のフォントの大きさや色を変更する場合は、以下のように指定します。

text = '<font size=18>' + 'テスト' + '</font>'
text = '<font color=red>' + 'テスト' + '</font>'

一部のフォントの情報をまとめて変更する

一部のフォントの大きさや色を段落として変更する場合は、以下のように指定します。

FONT_FILE = '/home/www/wsgi/xxx/static/font/ipaexg.ttf'
FONT_NAME = 'ipaexg'
pdfmetrics.registerFont(TTFont(FONT_NAME, FONT_FILE))

style = ParagraphStyle(name='Normal', fontName=FONT_NAME, fontSize=12, leading=12, textColor='Blue')
text  = 'フォントの情報を変更。' + '<br />'
text += '変更完了'
story.append( Paragraph(text, style) )
# 段落の間隔
story.append( Spacer(1, 3 * mm) )

使用するフォント

ReportLabが内包するフォント(HeiseiMin-W3とHeiseiKakuGo-W5)を使うと、Webブラウザによっては「髙」、「経」などが文字化けするのでPDFにTTFフォントを埋め込みます。
当初は「源真ゴシック」を使いましたが、レイアウトの都合から「IPAexフォント」に変更しました。
IPAexフォント(ipaexg.ttf)は「static/font/」以下に置きます。

IPAexフォント

IPAexフォントおよびIPAフォントについて | 一般社団法人 文字情報技術促進協議会

源真ゴシック

源真ゴシック (げんしんゴシック) | 自家製フォント工房
源真ゴシック (げんしんゴシック) は、Adobeのオープンソースフォント「源ノ角ゴシック」を TrueType 形式に変換し、普段使いにおける使い勝手を重視したカスタマイズを施したフリーフォントです...

改ページの条件について

今回の仕様では良い案が見つからなかったので、トライアンドエラーになってしまいました。

  • データには複数の項目があり、特に備考欄は文字数が多くなるので1件の行数が可変になる。
  • データを書き込む前にページの印刷可能な余白を計算し、足りなければ改ページする。
  • 使用するフォントによって印刷できる行数が変化するのでトライアンドエラーで行う。
  • 1ページに表示できる行数はヘッダ部を除いた余白なので実際に印刷して確認する。(line_limit変数)
  • 備考欄の1行の文字数も実際に印刷して確認する。(str_limit変数)
  • 1つの注文の行数は、備考欄に許可する文字数に依存するので実際に印刷して確認する。(oneline変数)

PDFを自動でダウンロードさせる

ボタンを押した(submit:POST)ときに処理する。
一時ファイルを作成するには書き込み権限が付与されたディレクトリが必要だが、スクリプト直下では危険なので別のディレクトリ「PDFDIR」を指定する。
一時ファイルは残しても構わないが気持ち悪いので「os.remove(filename)」で削除する。

ReportLabとHTMLタグ

ソースに記述している「&nbsp;」は「No Break Space」のこと。
ReportLabは一部のHTMLタグを記述できる。(使用可能なタグ一覧は見つけていない。)
ただし、文章中で部分的に文字色や大きさを変更する場合は「style」で指定する。

ReportLabのページ番号の付加

ページ番号の付加は以下を参考にしました。
doc.build関数にあるcustom_dataで1ページ目と2ページ目以降に異なるデータを渡すことができます。
PDFに表示する内容をcustom_data変数によって場合分けができるので便利です。

Parameter to reportlab header using Django
I'm generating PDF according this example and works fine, however I have a little problem in the par...

views.py

from django.shortcuts import render
from django.urls import reverse
from django.http import HttpResponse
from django.utils import timezone
from datetime import datetime
from wsgiref.util import FileWrapper
from django.views.generic import ListView
import os, mimetypes

# PDF作成ライブラリ ReportLab
from reportlab.rl_config import defaultPageSize
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_JUSTIFY, TA_RIGHT, TA_CENTER, TA_LEFT
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas

# Reportlabの_header_footerに引数(custom_data)を渡すために必要
from functools import partial

# 作成したモデルを参照する。
#from .models import TestModel
from testmodel.models import TestModel

# フォントのPathなど
FONT_FILE = '/home/www/wsgi/xxxx/static/font/ipaexg.ttf'
FONT_NAME = 'ipaexg'

# Djangoの管理下にあるディレクトリ「wsgi」以下では、パーミッションの問題でファイル操作できなかったため。
PDFDIR = '/tmp/'

class PDFDownloadView(ListView):
    template_name = 'pdf/pdf.html'
    model         = TestModel

    #
    # submit(POST)の処理
    #「pdfmail.html」でPDFをダウンロードする
    #
    def post(self, request, *args, **kwargs):
        # 条件を指定してデータベースから取得する。
        q = TestModel.objects.all().order_by('name')
        date_time = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
        filename = PDFDIR + date_time + '.xlsx'
        self.pdf_create(q, filename)
        wrapper  = FileWrapper(open(filename, 'rb'))
        response = HttpResponse(wrapper, content_type=mimetypes.guess_type(filename)[0])
        response['Content-Length']      = os.path.getsize(filename)
        response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(filename)
        # 一時ファイルの削除
        os.remove(filename)
        return response

    #
    # PDF作成
    #
    def pdf_create(self, q, fname):
        # ページ番号の付加。
        class NumberedCanvas(canvas.Canvas):
            def __init__(self, *args, **kwargs):
                canvas.Canvas.__init__(self, *args, **kwargs)
                self._saved_page_states = []

            def showPage(self):
                self._saved_page_states.append(dict(self.__dict__))
                self._startPage()

            def save(self):
                #add page info to each page (page x of y)
                num_pages = len(self._saved_page_states)
                for state in self._saved_page_states:
                    self.__dict__.update(state)
                    self.draw_page_number(num_pages)
                    canvas.Canvas.showPage(self)
                canvas.Canvas.save(self)

            def draw_page_number(self, page_count):
                self.setFont(FONT_NAME, 10)
                self.drawRightString(245 + PAGE_WIDTH / 2.0, PAGE_HEIGHT - 40, '%d / %d ページ' % (self._pageNumber, page_count))

        # 全ページにヘッダを印刷するために1ページ目と2ページ以降の関数を共通とする。
        # 座標はTry & Errorで算出した。
        def _header_footer(canvas, doc, custom_data):
            s_date          = datetime.now().strftime('%Y年%m月%d日')
            originator_name = ''
            originator_tel  = ''
            originator_fax  = ''
            if custom_data == 'xxxxx':
                originator_name = '差出人の名称1'
                originator_tel  = 'TEL:〇〇〇〇-〇〇-〇〇〇〇'
                originator_fax  = 'FAX:●●●●-●●-●●●●'
            else:
                originator_name = '差出人の名称2'
                originator_tel  = 'TEL:△△△△-△△-△△△△'
                originator_fax  = 'FAX:▲▲▲▲-▲▲-▲▲▲▲'

            canvas.saveState()
            style11 = ParagraphStyle(name='Normal', fontName=FONT_NAME, fontSize=20, leading=25)
            style12 = ParagraphStyle(name='Normal', fontName=FONT_NAME, fontSize=12, alignment=TA_RIGHT)
            style   = ParagraphStyle(name='Normal', fontName=FONT_NAME, fontSize=14, leading=20)
            text    = 'FAX用紙'
            line11  = Paragraph(text, style11)
            text    = s_date
            line12  = Paragraph(text, style12)
            text    = '【送付先】' + <br>' + '送付先名称' + <br>' + 'TEL:' + 送付先tel + <br>' + 'FAX:' + 送付先fax
            line21  = Paragraph(text, style)
            text    = '【発信元】' + <br>' + originator_name + <br>' + originator_tel + <br>' + originator_fax
            line22  = Paragraph(text, style)
            data    = [
                [line11, line12],
                [line21, line22],
            ]
            table = Table(data, colWidths=(220, 300))
            table.setStyle(TableStyle([
                ('BOX', (0, 0), (-1, -1), 1, colors.black),
                ('INNERGRID', (0, 0), (-1, -1), 0.20, colors.black),
                ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
            ]))
            w, h = table.wrapOn(canvas, doc.width, doc.topMargin)
            table.drawOn(canvas, doc.leftMargin, doc.height + doc.topMargin - h + 20)
            canvas.restoreState()

        #
        # データを処理してPDFに書き込む
        #
        styles             = getSampleStyleSheet()
        my_style           = styles['Normal']
        my_style.name      = 'Animal Center'
        my_style.fontName  = FONT_NAME
        my_style.fontSize  = 14
        my_style.alignment = TA_LEFT
        my_style.leading   = 18 # 段落内の行間
        PAGE_WIDTH         = defaultPageSize[0]
        PAGE_HEIGHT        = defaultPageSize[1]
        doc                = SimpleDocTemplate(fname, leading=100, rightMargin=30, leftMargin=30, topMargin=36, bottomMargin=72)
        story              = [Spacer(1, 50 * mm)]

        age = ''
        ct  = 0

        # 1ページに表示できる行数(使用したフォントと上記の条件で印刷して目視する)
        line_limit = 30
        # 備考の1行の文字数(使用したフォントと上記の条件で印刷して目視する)
        str_limit  = 40
        # 1注文の最大行数(固定)(使用したフォントと上記の条件で印刷して決定する)
        oneline = 7
        # 累積の行数
        line_cnt = 0
        # 処理中の注文の行数
        itemline_cnt = 0

        # 「f.content」は「備考」データを指す。
        for f in q:
            # 改ページの判別
            # 1ページに表示できる行数を設定して、処理中の行数と印刷可能な残り行数を計算する。
            itemline_cnt = oneline - 1 + int(len(f.content) / str_limit)
            if itemline_cnt > line_limit - line_cnt or oneline > line_limit - line_cnt:
                story.append( PageBreak() )
                story.append( Spacer(1, 50 * mm) )
                line_cnt = 0
            line_cnt += itemline_cnt

            # 「(変数)」はデータベースから取得した各変数を指す。
            text  = '番号:' + '(変数)番号' + '<br>'
            text += '(変数)商品の名前' + '&nbsp;&nbsp;&nbsp;' + '(変数)数' + '個' + '<br>'
            text += '<font size=18>' + '(変数)人名' + '</font>' + '<br>'
            text += '納品日:' + '(変数)希望日'
            if f.content != '':
                text += '<br>' + '【備考】' + '(変数)備考に記載された文章'
            text += '<br>'
            text += '-----------------------------------------------------------------------'
            story.append( Paragraph(text, my_style) )
            story.append( Spacer(1, 3 * mm) )

        # 1ページ目と2ページ目以降を指定してPDFを作成する。
        # 「NumberedCanvas」クラスはページ番号の付加用。
        # 「custom_data」を指定することで「_header_footer」関数で場合分けができる。
        doc.build(story, onFirstPage=partial(_header_footer, custom_data='変数を指定する'), onLaterPages=partial(_header_footer, custom_data='変数を指定する'), canvasmaker=NumberedCanvas)

        return

Comments