Next.js + @react-pdf/renderer 日本語PDF生成ガイド|SVGチャート描画のハマりどころと解決策


Next.jsで「診断結果をPDFでダウンロードしたい」「請求書をブラウザから生成したい」という要件が出たとき、候補になるのがjsPDF@react-pdf/rendererです。

本記事では、実際にSEO診断ツールのPDFレポート機能を実装した経験をもとに、@react-pdf/rendererを使った日本語PDF生成の方法を、SVGチャート描画のハマりどころや解決策とあわせて解説します。


jsPDF vs @react-pdf/renderer — どちらを選ぶべきか

まず2つのライブラリの特徴を比較します。

jsPDF@react-pdf/renderer
レイアウト座標を直接指定FlexboxベースのReactコンポーネント
日本語対応addFontでBase64変換が必要Font.registerでパス指定できる
TypeScript型定義が薄め充実している
複雑なレイアウト座標管理がつらいコンポーネントに分割できる
ファイルサイズ小さめやや大きめ

jsPDFは「表紙・サマリー・テーブル・チャートを組み合わせた複数ページ構成」になると、y座標を手動で管理し続ける必要があります。また日本語フォントを埋め込むにはttfをArrayBufferとして読み込んでBase64に変換する処理が必要です。

一方、@react-pdf/rendererはReactコンポーネントとして書けること、Font.registerがシンプルなこと、Flexboxでレイアウトできることが強みです。複数ページのレポートやダッシュボード型PDFには特に向いています。


セットアップ — インストールとフォント登録

インストール

npm install @react-pdf/renderer
# または
pnpm add @react-pdf/renderer

日本語フォントファイルを配置する

日本語フォント(NotoSansJP)を public/fonts/ に配置します。Google Fonts からttfをダウンロードするか、@fontsource/noto-sans-jp のnpmパッケージからコピーしてもOKです。

public/
  fonts/
    NotoSansJP-Medium.ttf

フォント登録関数を作る

フォント登録は複数回呼ばれると無駄なので、登録済みフラグで制御します。path.join(process.cwd(), ...) を使うのがポイントで、相対パスだとサーバーサイドで動かしたときにパスが解決できずエラーになります。

// lib/pdf/register-fonts.ts
import { Font } from "@react-pdf/renderer"
import path from "path"

let registered = false

export function registerFonts() {
  if (registered) return
  Font.register({
    family: "NotoSansJP",
    src: path.join(process.cwd(), "public/fonts/NotoSansJP-Medium.ttf"),
  })
  registered = true
}

基本的なPDFコンポーネントの書き方

@react-pdf/rendererはDOMではなく独自のコンポーネント体系を持ちます。主なコンポーネントは以下の通りです。

コンポーネント役割
DocumentPDF全体のルート
Page1ページ分。size=”A4″ でA4サイズ
Viewdivのような箱。Flexboxで配置
Textテキスト表示。fontFamily指定必須
StyleSheet.createスタイル定義
import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer"

const styles = StyleSheet.create({
  page: {
    fontFamily: "NotoSansJP",
    fontSize: 10,
    padding: 40,
    backgroundColor: "#FFFFFF",
  },
  heading: {
    fontSize: 16,
    fontWeight: "bold",
    marginBottom: 12,
  },
})

export function SampleDocument() {
  return (
    <Document title="サンプルPDF">
      <Page size="A4" style={styles.page}>
        <View>
          <Text style={styles.heading}>SEO診断レポート</Text>
          <Text>診断結果を日本語で表示できます。</Text>
        </View>
      </Page>
    </Document>
  )
}

fontFamily: "NotoSansJP" をページレベルで指定しておくと、子要素全体に継承されます。


SVGチャート描画のハマりどころと解決策

スコアをビジュアルに表示するために、SVGで円形のプログレスチャートを実装しようとして、2つの大きな壁にぶつかりました。


問題1: SVGのTextでtextAnchorやfontSizeがtype errorになる

@react-pdf/rendererにはSvg、Circle、Path、Text等のSVGコンポーネントが用意されています。しかし、SVG内の Text は通常のPDF用 Text と同名なのでimportが競合します。aliasを付けても textAnchorfontSize をpropsに渡すと型エラーになります。

解決策: SVGの上にView/Textを position: “absolute” で重ねる

SVGには図形だけを描いて、テキストの配置はReactPDFのコンポーネント側で行う方法が最もスッキリします。

import { View, Text, Svg, Circle } from "@react-pdf/renderer"

export function ScoreCircle({ score, maxScore, size = 100 }: Props) {
  return (
    <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
      <Svg width={size} height={size} style={{ position: "absolute" }}>
        <Circle cx={size / 2} cy={size / 2} r={size / 2 - 8}
          stroke="#E2E8F0" strokeWidth={6} fill="none" />
      </Svg>
      <Text style={{ fontSize: size * 0.28, fontWeight: "bold" }}>{score}</Text>
      <Text style={{ fontSize: size * 0.12, color: "#64748B" }}>/ {maxScore}</Text>
    </View>
  )
}

問題2: CircleでstrokeDashoffsetが使えない

通常のSVGでは strokeDasharraystrokeDashoffset の組み合わせで円弧を描きますが、@react-pdf/rendererの CirclestrokeDashoffset が型定義に存在せずTypeエラーになります。

解決策: Path + describeArc関数で円弧を直接描く

三角関数で始点と終点の座標を計算して、SVGのArcコマンドでパスを生成します。

function describeArc(
  cx: number,
  cy: number,
  r: number,
  startAngle: number,
  endAngle: number
): string {
  const x1 = cx + r * Math.cos(startAngle)
  const y1 = cy + r * Math.sin(startAngle)
  const x2 = cx + r * Math.cos(endAngle)
  const y2 = cy + r * Math.sin(endAngle)
  const largeArc = Math.abs(endAngle - startAngle) > Math.PI ? 1 : 0
  return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
}

この関数を使ってスコアに応じた円弧を描画します。

export function ScoreCircle({ score, maxScore, size = 100 }: Props) {
  const cx = size / 2
  const cy = size / 2
  const r = size / 2 - 8
  const pct = maxScore > 0 ? score / maxScore : 0
  const color = pct >= 0.8 ? "#22C55E" : pct >= 0.6 ? "#F59E0B" : "#EF4444"

  const startAngle = -Math.PI / 2
  const endAngle = startAngle + 2 * Math.PI * pct

  return (
    <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
      <Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ position: "absolute" }}>
        <Circle cx={cx} cy={cy} r={r} stroke="#E2E8F0" strokeWidth={6} fill="none" />
        {pct > 0 && (
          <Path
            d={describeArc(cx, cy, r, startAngle, endAngle)}
            stroke={color}
            strokeWidth={6}
            fill="none"
            strokeLinecap="round"
          />
        )}
      </Svg>
      <Text style={{ fontSize: size * 0.28, fontWeight: "bold" }}>{score}</Text>
      <Text style={{ fontSize: size * 0.12, color: "#64748B" }}>/ {maxScore}</Text>
    </View>
  )
}

ゲージチャート(半円)の場合は、startAngleを Math.PI、endAngleを 0 にすれば下半分をくり抜いた半円になります。ただしSVGの座標系ではy軸が反転するため、describeArc関数の符号を変える必要がある点に注意してください。


API Route経由でPDFを配信する

Next.js App RouterのAPI Routeからサーバーサイドでバイナリを返す実装です。

// app/api/generate-pdf/route.ts
import { NextResponse } from "next/server"
import { renderToBuffer } from "@react-pdf/renderer"
import { registerFonts } from "@/lib/pdf/register-fonts"
import { ReportDocument } from "@/lib/pdf/report-document"

export async function POST(request: Request) {
  try {
    const body = await request.json()

    registerFonts()

    const buffer = await renderToBuffer(
      ReportDocument({ results: body.results, date: body.date })
    )

    const uint8 = new Uint8Array(buffer)

    return new NextResponse(uint8, {
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": `attachment; filename="report.pdf"`,
      },
    })
  } catch (error) {
    console.error("PDF generation failed:", error)
    return NextResponse.json(
      { error: "PDF generation failed" },
      { status: 500 }
    )
  }
}

renderToBuffer の戻り値は Buffer ですが、NextResponse に渡すには new Uint8Array(buffer) への変換が必要です。この一行が抜けると、バイナリが正しく送信されず壊れたPDFが返ってきます。

クライアント側のダウンロード処理

async function downloadPDF(data: ReportData) {
  const response = await fetch("/api/generate-pdf", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  })

  if (!response.ok) throw new Error("PDF生成に失敗しました")

  const blob = await response.blob()
  const url = URL.createObjectURL(blob)
  const a = document.createElement("a")
  a.href = url
  a.download = "seo-report.pdf"
  a.click()
  URL.revokeObjectURL(url)
}

実際のPDF出力構成例

今回の実装では、以下の5ページ構成のPDFレポートが生成されます。

  1. 表紙ページ: ロゴ・タイトル・URL・診断日時
  2. エグゼクティブサマリー: 総合スコア(円形チャート)、カテゴリ別スコアカード
  3. チェック項目一覧: 全診断項目の結果テーブル(good/warning/badのステータス付き)
  4. 改善提案: badとwarningの項目を優先度付きでリストアップ
  5. Core Web Vitals: LCP・CLS・INPのゲージチャート、モバイル/デスクトップ比較

各ページはコンポーネントに分割されているため、ページを増減させたいときは Document に子コンポーネントを追加・削除するだけで対応できます。これが@react-pdf/rendererを選んだ最大の恩恵です。


まとめ

@react-pdf/rendererを選ぶメリット

  • コンポーネントで分割できるため複数ページ構成でも管理しやすい
  • FlexboxでレイアウトできるのでCSSの知識がそのまま活きる
  • 日本語フォント登録が Font.register 1行で済む
  • TypeScriptの型が充実している

実装時の注意点

問題解決策
SVGのTextでtextAnchor等がtype errorView/Textオーバーレイで分離する
strokeDashoffsetが型定義にないPath + describeArc関数で円弧を自前で描く
renderToBufferの戻り値がNextResponseに渡せないnew Uint8Array(buffer)で変換する
Font.registerのパスが解決できないprocess.cwd()で絶対パスを指定する

SVGの制約はやや面倒ですが、三角関数でパスを計算する方法に慣れると自由度は高くなります。複数ページのレポートや日本語を含むPDF生成が必要なNext.jsプロジェクトでは、@react-pdf/rendererが有力な選択肢です。


Q. jsPDFと@react-pdf/rendererはどう使い分けますか?

単純な1ページPDF(請求書の控えなど)ならjsPDFで十分です。複数ページ構成やチャート・テーブルを含むレポート型PDFなら@react-pdf/rendererの方がコンポーネント分割できて管理しやすくなります。

Q. @react-pdf/rendererでクライアントサイドレンダリングはできますか?

可能ですが、Font.registerのパス解決がサーバーサイドと異なります。クライアントサイドではフォントファイルのURLを直接指定する必要があります。サーバーサイドでrenderToBufferを使い、API Route経由で配信する方法が最も安定します。

Q. describeArc関数を使わずに円弧を描く方法はありますか?

@react-pdf/rendererのSVGサポートが改善されればstrokeDashoffsetが使えるようになる可能性がありますが、現時点(2026年4月)では型定義に含まれていません。describeArcによるPath描画が最も確実な方法です。