はじめに
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フォント

源真ゴシック
改ページの条件について
今回の仕様では良い案が見つからなかったので、トライアンドエラーになってしまいました。
- データには複数の項目があり、特に備考欄は文字数が多くなるので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変数によって場合分けができるので便利です。
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 += '(変数)商品の名前' + ' ' + '(変数)数' + '個' + '<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