Django:ReportLabによるPDFデータ作成で苦戦したところ

Django:ReportLabによるPDFデータ作成で苦戦したところ
『ReportLab』はDjangoでPDF出力をしようとしたときによく選択されるライブラリなのだそうです。
最近、抱えていたプログラムの方の案件がひと段落したので「他を探そう」と思ったのですが「新しいポートフォリオをくれ」と言われます。
毎回Excelで書きだすのも大変なので「仕事履歴をDB化して常に最新を出力できるように仕込んでしまおう」と思い実装してみました。
で、所感ですが「めんどくせ~~~」と。プログラミングの初体験がこれだったら死ねるかも。
少しでも楽に作る方法を含め、まとめていきたいと思います。
ReportLabが使える2つの方法
私は勉強を兼ねていたので大変な方を選択しましたが『ReportLab』では2つの方法でPDFを作成することができます。
- ゼロ(白紙)から罫線などの書式も含めファイルを作成する
- あらかじめ用意した書式PDFを利用してファイルを作成する
[手段1]の方が面倒です。何が面倒でどうすると楽になるかは後述するとして、まずは[手段2]の構成から。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
from io import BytesIO from reportlab.pdfgen import canvas from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.pdfbase import pdfmetrics from PyPDF2 import PdfFileWriter, PdfFileReader from reportlab.lib.pagesizes import A4 from reportlab.lib.colors import pink, black, red, blue, green def CreatePDFWithTemplate(request): packet = BytesIO() new_pdf = PdfFileReader(packet) # 書式の入ったPDFを読み込む existing_pdf = PdfFileReader(open("sample.pdf", "rb")) output = PdfFileWriter() page = existing_pdf.getPage(0) page.mergePage(new_pdf.getPage(0)) output.addPage(page) (後略) |
簡潔ですね。ポイントは【書式PDFにスキャナで読み込んだデータを使わない事】です。
これを行ってしまうと書き込み時にForループが使えなくなってしまいます。
理由は「スキャナを使うと罫線の位置などが微妙にずれているから」なので、Forループで一覧から必要数出力しようとしても「2列目まではOKだけど3列目が罫線の上に文字が入った」なんて事が発生します。
使う際はExcelなどで作成した書式をそのままPDF出力したものを利用しましょう。
書式からすべてをReportLabで作成するとズレのないPDFを作成できる
罫線なんかを含め白紙からすべての出力をコントロールすればズレは生じません。
でもね、行数と手数が増えて、何よりも罫線や枠の位置決めが超面倒だったりします。
このあたりはLaravelで使っていた【phpspreadsheet】とか優れてますよね。
HTMLで書いたViewをそのままPDFに出力してくれるから慣れたコードですべてを構築できる。
うん、素晴らしい。
PeportLabの面倒な所をまとめてみるとこんな感じ
- ポイントとミリ(またはcm)との変換/換算が面倒
- 罫線の重なりで一部分だけ線が太くなったりすることがある
- 画像の配置とサイズ変更
- 行数が増えて見難い&問題個所を探し難い
書式PDFの読み込みで構築すると[1,2,4]が大幅改善されるので苦労は少なくなります。
ゼロベースで構築しなくてはいけない時に楽するポイント
それでもゼロベースで作成しなくちゃならない場合は次のポイントを押さえましょう。
- 書式(フォーマット)はグリッド設計にする
- 設計したグリッドは出力して手元に置いておく
- 1グリッドのサイズを記録しておく(forループで多用します)
- PeportLabへのサイズ指示は mm に統一する ※ptでもよいのですが慣れるまで勘は働きにくいです。
書式フォーマット(グリッド)をExcelで書こうとすると悩むところ

Excelは[ mm ]という単位で作られていません。ptとピクセルでコントロールすることになります。
- 1インチ = 25.4mm
- 1ポイント = 1インチの72分割 = 25.4 ÷ 72 mm = 0.352777777777778 mm
- A4サイズ = 210×297mm = 595.2759 × 841.8898pt
Excelの嫌なところは縦横を同じピクセルにしてセルを正方形に見立ててみても、縦横のポイント値が大きく異なっていて何を信じればよいかわからない点です。
ポイントって決まった値になる筈なのですが、Excelではあまり信用してはいけません。
そして恐ろしいことに、縦横のピクセルサイズを合わせても縦横の出力サイズは同じになりません。
こういう時は自分で決めたグリッドをベースに枠の配置を行っていくのが最善手段です。
グリッドの例
これは私がよく使うグリッドです。
Excelで縦横25ピクセルにすると、標準的な有効印刷範囲に横25マス、縦40マスを差し込むことができます。
1マスサイズは 横6.76mm×縦6.35mm
上下左右に入っている数字は、左下を基準点としたときの高さと幅です。『PeportLab』では左下角が基準点(0,0)になるのでそれに合わせてグリッド数値を加算していきます。
このグリッドでは、上下に24.675mm 左右に20.5mm の余白ができるので、標準印刷に近い余白を確保できます。
PeportLabへのサイズ指示を【mm】に統一する
PeportLabでは左下の角を基点としてテーブルや文字をどこに配置するか指示を出します。
上のグリッド例の16pt文字のAであれば【 p.drawString(27.26 * mm, 234.23 * mm, ‘A’) 】といった形です。
この[ 数値 * mm ] を利用するためには必要な関数をUSEしなくてはなりません。そんなのを諸々含めた具体例が下です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
from reportlab.lib.units import mm from reportlab.platypus import Table from reportlab.platypus import TableStyle from reportlab.lib import colors """ Portfolioの書式設定 """ def portfolio_first_page(p, font_m, font_g, today): # ポートフォリオ タイトル font_size = 16 p.setFont(font_m, font_size) # (1)書き出し(横位置, 縦位置, 文字) p.drawString(20.5 * mm, 263 * mm, 'Profile') # (2)作成日 p.setFont(font_m, 9) p.drawString(121.14 * mm, 261 * mm, str(today.year) + ' 年 ' + str(today.month) + ' 月 ' + str(today.day) + ' 日現在') # (3)証明写真 # tableを作成 data = [ [' 証明写真'], ] # tableの大きさ指定 table = Table(data, colWidths=30 * mm, rowHeights=40 * mm) # tableの装飾設定 table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 12), # フォントサイズ ('BOX', (0, 0), (0, 0), 1, colors.black), # 罫線 ('VALIGN', (0, 0), (0, 0), 'MIDDLE'), # フォント位置 ])) # tableの位置 table.wrapOn(p, 159.5 * mm, 226 * mm) table.drawOn(p, 159.5 * mm, 226 * mm) # (4) プロフィール data = [ ['フリガナ',''], ['氏名',''], ['生年月日',''], ] table = Table(data, colWidths=(27.04 * mm,108.16 * mm), rowHeights=(6.35 * mm, 12.7 * mm, 6.35 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (1, 2), font_g, 12), ('BOX', (0, 0), (1, 2), 1, colors.black), ('INNERGRID', (0, 0), (1, 2), 1, colors.black), ('VALIGN', (0, 0), (1, 2), 'MIDDLE'), ])) table.wrapOn(p, 20.5 * mm, 234.2 * mm) table.drawOn(p, 20.5 * mm, 234.2 * mm) # (5)住所 data = [ ['連絡先','〒',''], ['','E-MAIL',''], ] table = Table(data, colWidths=(27.04 * mm,27.04 * mm,81.12 * mm), rowHeights=(6.35 * mm,6.35 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 12), ('FONT', (1, 0), (2, 2), font_m, 9), ('BOX', (0, 0), (0, 1), 1, colors.black),#外枠囲う ('LINEBELOW', (1, 0), (2, 1), 1, colors.black),#下部横線 ('LINEAFTER', (2, 0), (2, 1), 1, colors.black),#右側縦線 ('VALIGN', (0, 0), (0, 0), 'MIDDLE'), ('SPAN', (0, 0), (0, 1)), ])) table.wrapOn(p, 20.5 * mm, 221.5 * mm) table.drawOn(p, 20.5 * mm, 221.5 * mm) # (6)ブロク/SNS data = [ ['ブロク/SNS','Twitter:','','Facebook:',''], ['','Blog:','','',''], ] table = Table(data, colWidths=(27.04 * mm,27.04 * mm,40.56 * mm,40.56 * mm,33.8 * mm), rowHeights=(6.35 * mm,6.35 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('FONT', (1, 0), (4, 2), font_m, 9), ('BOX', (0, 0), (0, 1), 1, colors.black), ('LINEBELOW', (1, 0), (4, 1), 1, colors.black),#下部横線 ('LINEAFTER', (4, 0), (4, 1), 1, colors.black),#右側縦線 ('LINEABOVE', (4, 0), (4, 0), 1, colors.black),#上部横線 ('VALIGN', (0, 0), (0, 0), 'MIDDLE'), ('SPAN', (0, 0), (0, 1)), ])) table.wrapOn(p, 20.5 * mm, 208.8 * mm) table.drawOn(p, 20.5 * mm, 208.8 * mm) # (7)PR文 data = [[""]] table = Table(data, colWidths=169 * mm, rowHeights=38.1 * mm) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('BOX', (0, 0), (0, 0), 1, colors.black), ('VALIGN', (0, 0), (0, 0), 'MIDDLE'), ])) table.wrapOn(p, 20.5 * mm, 170.7 * mm) table.drawOn(p, 20.5 * mm, 170.7 * mm) # (8)最近の案件 p.setFont(font_m, font_size) p.drawString(20.5 * mm, 158 * mm, '最近の案件') data = [["案件1"],["案件2"],["案件3"],["案件4"],] table = Table(data, colWidths=169 * mm, rowHeights=(32 * mm,32 * mm,32 * mm,32 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 3), font_g, 11), ('BOX', (0, 0), (0, 3), 1, colors.black), ('LINEBELOW', (0, 0), (0, 3), 1, colors.black),#下部横線 ('VALIGN', (0, 0), (0, 3), 'TOP'), ])) table.wrapOn(p, 20.5 * mm, 24.68 * mm) table.drawOn(p, 20.5 * mm, 24.68 * mm) # 1枚目終了 return def portfolio_second_page(p, font_m, font_g, today): """ 2ページ目 スキルシート """ # (9)書き出しSkill_Sheet font_size = 16 p.setFont(font_m, font_size) p.drawString(20.5 * mm, 263 * mm, 'Skill_Sheet') # (10)プログラミング言語 data = [["プログラミング言語"],[""]] table = Table(data, colWidths=169 * mm, rowHeights=(6.35 * mm,38.1 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('BOX', (0, 0), (0, 1), 1, colors.black), ('VALIGN', (0, 0), (0, 1), 'MIDDLE'), ('ALIGN', (0, 0), (0, 1), 'CENTER'), ('BACKGROUND', (0, 0), (0, 0), '#d3d3d3'), ])) table.wrapOn(p, 20.5 * mm, 215.2 * mm) table.drawOn(p, 20.5 * mm, 215.2 * mm) # (11)フロントエンド data = [["フロントエンド"],[""]] table = Table(data, colWidths=81.12 * mm, rowHeights=(6.35 * mm,50.8 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('BOX', (0, 0), (0, 1), 1, colors.black), ('VALIGN', (0, 0), (0, 1), 'MIDDLE'), ('ALIGN', (0, 0), (0, 1), 'CENTER'), ('BACKGROUND', (0, 0), (0, 0), '#d3d3d3'), ])) table.wrapOn(p, 20.5 * mm, 151.7 * mm) table.drawOn(p, 20.5 * mm, 151.7 * mm) # (12)バックエンド data = [["バックエンド"],[""]] table = Table(data, colWidths=81.12 * mm, rowHeights=(6.35 * mm,50.8 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('BOX', (0, 0), (0, 1), 1, colors.black), ('VALIGN', (0, 0), (0, 1), 'MIDDLE'), ('ALIGN', (0, 0), (0, 1), 'CENTER'), ('BACKGROUND', (0, 0), (0, 0), '#d3d3d3'), ])) table.wrapOn(p, 108.38 * mm, 151.7 * mm) table.drawOn(p, 108.38 * mm, 151.7 * mm) # (13)データベース data = [["データベース"],[""]] table = Table(data, colWidths=81.12 * mm, rowHeights=(6.35 * mm,50.8 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('BOX', (0, 0), (0, 1), 1, colors.black), ('VALIGN', (0, 0), (0, 1), 'MIDDLE'), ('ALIGN', (0, 0), (0, 1), 'CENTER'), ('BACKGROUND', (0, 0), (0, 0), '#d3d3d3'), ])) table.wrapOn(p, 20.5 * mm, 88.18 * mm) table.drawOn(p, 20.5 * mm, 88.18 * mm) # (14)インフラ/サーバー data = [["インフラ/サーバー"],[""]] table = Table(data, colWidths=81.12 * mm, rowHeights=(6.35 * mm,50.8 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('BOX', (0, 0), (0, 1), 1, colors.black), ('VALIGN', (0, 0), (0, 1), 'MIDDLE'), ('ALIGN', (0, 0), (0, 1), 'CENTER'), ('BACKGROUND', (0, 0), (0, 0), '#d3d3d3'), ])) table.wrapOn(p, 108.38 * mm, 88.18 * mm) table.drawOn(p, 108.38 * mm, 88.18 * mm) # (15)その他/ライブラリ data = [["その他/ライブラリ"],[""]] table = Table(data, colWidths=81.12 * mm, rowHeights=(6.35 * mm,50.8 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('BOX', (0, 0), (0, 1), 1, colors.black), ('VALIGN', (0, 0), (0, 1), 'MIDDLE'), ('ALIGN', (0, 0), (0, 1), 'CENTER'), ('BACKGROUND', (0, 0), (0, 0), '#d3d3d3'), ])) table.wrapOn(p, 20.5 * mm, 24.68 * mm) table.drawOn(p, 20.5 * mm, 24.68 * mm) # (16)その他/ハード・ネットワーク data = [["その他/ハード・ネットワーク"],[""]] table = Table(data, colWidths=81.12 * mm, rowHeights=(6.35 * mm,50.8 * mm)) table.setStyle(TableStyle([ ('FONT', (0, 0), (0, 0), font_g, 11), ('BOX', (0, 0), (0, 1), 1, colors.black), ('VALIGN', (0, 0), (0, 1), 'MIDDLE'), ('ALIGN', (0, 0), (0, 1), 'CENTER'), ('BACKGROUND', (0, 0), (0, 0), '#d3d3d3'), ])) table.wrapOn(p, 108.38 * mm, 24.68 * mm) table.drawOn(p, 108.38 * mm, 24.68 * mm) return |
文字サイズはptで行っていますが、枠線の配置などは全てmmで行っているのがわかると思います。
そして、指示している場所は作成したグリッドの交点となっています。
書式フォーマットと記載内容については関数を分けてあげる
ついついフォーマットとデータの投入を1つの関数で行ってしまいがちですが、これは絶対に分けた方が良いです。
理由は簡単で、修正が楽になるから。
長いスクリプトから要件個所を探すのは中々に大変ですが、小分けにしてしまえば場所の特定は容易です。
また、分けているからこその特権『使いまわし』も可能となります。
罫線までスクリプトで組んでしまえば、あとはリストを作ってループで書き込むだけ
フォーマットがあって記載内容のリストを作成できれば、後はforループで書いていけばOKです。
グリッド設計してあれば上下左右の増える幅(レコード毎にどれ位ずらすか)は算出できます。
まとめ
このフォーマットを作るのにまる一日。
もう面倒ったらないです。
忙しい時には絶対やらない作業でした。
でもまぁ、一回作っちゃえばあとはレコードが増える度に内容更新してくれるんだから便利ですよね。
-
前の記事
Django:教師あり機械学習を実装してみる「scikit-learn」 2021.03.08
-
次の記事
Docker:DjangoのSPA化でVue.jsのコンテナを作成したけど起動しなかった件 2021.03.18
コメントを残す