業務システムを作っていると、必ずと言っていいほど「請求書をPDFで出したい」「帳票をダウンロードできるようにしたい」という要件が降ってきますよね。
JavaでPDFを生成するライブラリは複数ありますが、Spring Bootで実装する際に最初に悩むのが ライブラリ選定 と 日本語フォント対応 の2点です。今回はOpenPDFとopenhtmltopdf(Flying Saucerの後継)を使って、Thymeleafテンプレートから請求書PDFを生成する実装をひととおり追ってみます。
なお、本記事のバージョン表記(OpenPDF 1.3.34 / openhtmltopdf 1.0.10)は執筆時点のものです。最新版は各プロジェクトのGitHub Releasesで確認してください。
ライブラリ選定で迷ったらまずライセンスを確認
候補に挙がるのはだいたいこのあたりです。
- iText 7 は高機能ですが、AGPLまたは商用ライセンスでの提供となるため、クローズドな業務システムでは商用契約が必要になりがちです。
- OpenPDF はiText 4からフォークされた後継で、 LGPL 3.0 / MPL 2.0 のデュアルライセンス です。商用システムに組み込みやすいのが特徴です。
- openhtmltopdf(Flying Saucerのfork)はHTML/CSSからPDFを生成する高レベルライブラリで、内部でPDFBoxやOpenPDFを使う構成になっています。
Apache PDFBoxやJasperReportsも選択肢ですが、PDFBoxは低レベルすぎて帳票には向かず、JasperReportsは独自テンプレート(.jrxml)の学習コストがあります。
選定の目安はこんな感じです。
- 細かいレイアウトをコードで制御したい場合は OpenPDF。
- HTMLとCSSで帳票デザインを組みたい場合は openhtmltopdf。
- すでにThymeleafを使っているなら openhtmltopdf が圧倒的に楽。
本記事では実務で使い勝手の良い「Thymeleaf + openhtmltopdf」を中心に解説しつつ、補助的にOpenPDFの低レベルAPIにも触れます。
依存関係を追加する
Mavenの場合は以下を追加します。
<dependencies>
<!-- OpenPDF (低レベルAPIを使う場合のみ) -->
<dependency>
<groupId>com.github.librepdf</groupId>
<artifactId>openpdf</artifactId>
<version>1.3.34</version>
</dependency>
<!-- HTML→PDF -->
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-core</artifactId>
<version>1.0.10</version>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
</dependencies>
Thymeleafは spring-boot-starter-thymeleaf を入れていればそのまま使えます。Thymeleafの基本については Spring BootでThymeleafを使う方法 も参考にしてください。
OpenPDFで低レベルにPDFを作る
本筋に入る前に、「HTMLを介さず、コードで直接書く」パターンを最小限だけ見ておきます。レイアウトが極めて単純な場合や、既存コードの一部にちょっとした出力を埋め込みたい場面で使えます。
import com.lowagie.text.Document;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import java.io.ByteArrayOutputStream;
public byte[] createSimplePdf() throws Exception {
try (var baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
document.add(new Paragraph("請求書"));
PdfPTable table = new PdfPTable(3);
table.addCell("品名");
table.addCell("単価");
table.addCell("金額");
table.addCell("開発作業");
table.addCell("10,000");
table.addCell("100,000");
document.add(table);
document.close();
return baos.toByteArray();
}
}
旧API の com.lowagie.text.Table はlegacy扱いなので、現行のOpenPDFでは PdfPTable を使うのがおすすめです。このコードのままだと日本語が「豆腐」(□□□)になります。OpenPDFは標準でASCII系フォントしか持たないため、日本語を出すには明示的にフォントを埋め込む必要があります。
日本語フォントを埋め込む
商用利用しやすい日本語フォントとして IPAexゴシック や Noto Sans JP がよく使われます。.ttf ファイルを src/main/resources/fonts/ に置いて読み込みます。
import com.lowagie.text.Font;
import com.lowagie.text.pdf.BaseFont;
import org.springframework.core.io.ClassPathResource;
private Font japaneseFont() throws Exception {
var resource = new ClassPathResource("fonts/ipaexg.ttf");
BaseFont base = BaseFont.createFont(
resource.getURL().toString(),
BaseFont.IDENTITY_H,
BaseFont.EMBEDDED
);
return new Font(base, 10);
}
BaseFont.EMBEDDED を指定すると、フォントがPDFに埋め込まれ、フォントが入っていない環境でも正しく表示されます。サブセット埋め込み(使った文字だけ埋め込む)がデフォルトなので、ファイルサイズはそこまで膨らみません。
Thymeleaf + openhtmltopdfでHTML→PDF
ここからが本題です。実務で帳票を作るなら、HTMLとCSSで書けるこちらの方が圧倒的に楽です。デザインの修正もブラウザでプレビューしながらできます。
まずThymeleafテンプレート src/main/resources/templates/invoice.html を用意します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<style>
@font-face {
font-family: 'IPAexGothic';
src: url('fonts/ipaexg.ttf');
}
body { font-family: 'IPAexGothic', sans-serif; font-size: 10pt; }
h1 { font-size: 18pt; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #333; padding: 4px; }
.total { text-align: right; margin-top: 12px; }
tr { page-break-inside: avoid; }
</style>
</head>
<body>
<h1>請求書</h1>
<p>請求番号は <span th:text="${invoice.number}"></span> です。</p>
<p><span th:text="${invoice.customer}"></span> 御中</p>
<table>
<thead>
<tr><th>品名</th><th>数量</th><th>単価</th><th>金額</th></tr>
</thead>
<tbody>
<tr th:each="item : ${invoice.items}">
<td th:text="${item.name}"></td>
<td th:text="${item.quantity}"></td>
<td th:text="${item.unitPrice}"></td>
<td th:text="${item.amount}"></td>
</tr>
</tbody>
</table>
<p class="total">合計 <span th:text="${invoice.total}"></span> 円</p>
</body>
</html>
XHTML準拠で書く必要がある点だけ注意してください。<br> ではなく <br/> のように閉じタグを付けます。またopenhtmltopdfはCSS3 Paged Media(@page、page-break-*など)には対応していますが、flexboxやgridレイアウトは未サポートです。帳票はテーブルレイアウト中心で組むのが無難です。
サービス層では、ThymeleafでHTMLを文字列にレンダリングし、それをopenhtmltopdfに渡します。
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@Service
@RequiredArgsConstructor
public class InvoicePdfService {
private final TemplateEngine templateEngine;
public byte[] generate(Invoice invoice) throws IOException {
Context ctx = new Context();
ctx.setVariable("invoice", invoice);
String html = templateEngine.process("invoice", ctx);
try (var baos = new ByteArrayOutputStream()) {
var fontUrl = new ClassPathResource("fonts/ipaexg.ttf").getURL();
String baseUri = new ClassPathResource("templates/").getURL().toString();
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.useFont(() -> fontUrl.openStream(), "IPAexGothic");
builder.withHtmlContent(html, baseUri);
builder.toStream(baos);
builder.run();
return baos.toByteArray();
}
}
}
useFont でフォントを登録しておけば、CSS側の @font-face と組み合わせて確実に埋め込まれます。baseUri は画像やフォントの相対パスを解決する基準なので、これを忘れると画像が表示されません。
REST APIでダウンロードさせる
生成したPDFをHTTPレスポンスとして返します。CRUDの基本形は Spring BootでREST APIを作るチュートリアル と同じですが、レスポンスをbyte配列にして Content-Disposition を付けるのがポイントです。
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequiredArgsConstructor
public class InvoiceController {
private final InvoicePdfService pdfService;
private final InvoiceRepository invoiceRepository;
@GetMapping("/invoices/{id}/pdf")
public ResponseEntity<byte[]> download(@PathVariable Long id) throws IOException {
Invoice invoice = invoiceRepository.findById(id).orElseThrow();
byte[] pdf = pdfService.generate(invoice);
String filename = URLEncoder.encode("請求書.pdf", StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + filename)
.body(pdf);
}
}
ファイル名に日本語を含める場合は RFC 5987 に従って filename*=UTF-8''... 形式で指定します。これでChromeでもFirefoxでも正しいファイル名でダウンロードされます。ファイルダウンロード周りの話は Spring BootでMultipartFileを扱う方法 にもまとめています。
大きなPDFはストリーミング配信に
数百ページの帳票を byte[] で全部メモリに載せると、同時アクセスでヒープが一気に圧迫されます。StreamingResponseBody で直接OutputStreamに書き出す形にしましょう。
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@GetMapping("/reports/large.pdf")
public ResponseEntity<StreamingResponseBody> large() {
StreamingResponseBody body = out -> {
var fontUrl = new ClassPathResource("fonts/ipaexg.ttf").getURL();
String baseUri = new ClassPathResource("templates/").getURL().toString();
String html = renderLargeReportHtml();
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.useFont(() -> fontUrl.openStream(), "IPAexGothic");
builder.withHtmlContent(html, baseUri);
builder.toStream(out);
builder.run();
};
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.body(body);
}
openhtmltopdfは toStream(OutputStream) を受け付けるので、レスポンスストリームに直結できます。生成に時間がかかる場合は @Async で非同期化し、完成後にメール通知やS3配置するパターンも検討してみてください。
実装時にチェックしたいこと
動かしてみてうまくいかないとき、まず疑うポイントをいくつか挙げておきます。
日本語が豆腐になるときは、useFont でフォントを登録していないか、@font-face の font-family 名がCSS側と一致していないことがほとんどです。表の途中で改ページされてしまう場合は CSS で page-break-inside: avoid; を付けると改善します。CSSが思ったように効かない場合はflexboxなどの未サポート機能を使っていないか、テーブルレイアウトに置き換えられないかを検討してみてください。
また、古い記事を参考にしたコードに com.itextpdf 系の依存が残っていると、知らないうちにAGPLの間接依存が発生します。mvn dependency:tree で一度確認しておくと安心です。
まとめ
Spring BootでのPDF生成は、ライセンス的に安全なOpenPDFとopenhtmltopdfを組み合わせるのが現実的な選択肢です。ThymeleafでHTMLを組み、フォントを正しく埋め込めば、業務で必要な帳票はだいたいカバーできます。
まずはシンプルな請求書テンプレートから始めて、徐々にロゴや押印欄を足していくとスムーズに育てていけます。