業務システムを作っていると、必ずと言っていいほど「請求書を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(@pagepage-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-facefont-family 名がCSS側と一致していないことがほとんどです。表の途中で改ページされてしまう場合は CSS で page-break-inside: avoid; を付けると改善します。CSSが思ったように効かない場合はflexboxなどの未サポート機能を使っていないか、テーブルレイアウトに置き換えられないかを検討してみてください。

また、古い記事を参考にしたコードに com.itextpdf 系の依存が残っていると、知らないうちにAGPLの間接依存が発生します。mvn dependency:tree で一度確認しておくと安心です。

まとめ

Spring BootでのPDF生成は、ライセンス的に安全なOpenPDFとopenhtmltopdfを組み合わせるのが現実的な選択肢です。ThymeleafでHTMLを組み、フォントを正しく埋め込めば、業務で必要な帳票はだいたいカバーできます。

まずはシンプルな請求書テンプレートから始めて、徐々にロゴや押印欄を足していくとスムーズに育てていけます。