TalentX Tech Blog

Tech Blog

ReactでのPDFダウンロード機能の実装と技術選定時の判断ポイント

こんにちは、TalentXでフロントエンドを担当している大村です。

今回は、ReactアプリケーションでPDFダウンロード機能を実装した際の技術選定の経緯と、最終的な実装内容についてまとめます。

同じようにReactでPDFダウンロード機能を検討しているフロントエンドエンジニアの方の参考になれば幸いです。

背景

なぜフロントエンドでPDF生成するのか

PDF生成の実装方法として、大きくバックエンドで生成する方法とフロントエンドで生成する方法があります。

それぞれの一般的なメリット・デメリットは以下のとおりです。

バックエンドで生成する場合

  • サーバー側のリソースを使うため、クライアントの端末スペックに依存しない
  • Puppeteer や wkhtmltopdf など成熟したツールが豊富で、HTML/CSSをそのままPDF化しやすい
  • フォントや外部リソースの管理をサーバー側で一元化できる
  • 一方で、PDF生成用のサーバーサイド処理(APIエンドポイント、レンダリング環境等)を新たに構築・運用する必要がある
  • Puppeteer等を使う場合はヘッドレスブラウザの実行環境が必要になり、インフラコストやメンテナンスコストが増える

フロントエンドで生成する場合

  • サーバーサイドに新たな仕組みを構築する必要がなく、既存のフロントエンドの範囲内で完結できる
  • Reactのエコシステム内でコンポーネントベースに実装でき、UIとPDFのロジックを近い場所に配置できる
  • サーバーへの追加リクエストやインフラの追加コストが不要
  • 一方で、クライアント端末のスペックに依存するため、低スペック端末では生成に時間がかかる可能性がある
  • フォントファイルをクライアントに配信する必要があり、初回ロード時のファイルサイズが増える
  • ライブラリの制約(独自プリミティブの使用、日本語フォント対応など)に対処する必要がある

今回の選定

当初はバックエンド側でのPDF生成を検討していました。

しかし、今回はバックエンドの要件に見合ったPDF生成用のライブラリが見つかりませんでした。

そのため自前でPDF生成用の処理や実行環境を用意する必要があり、実装コストがかなりかかる見込みでした。

なのでフロントエンドでの生成方法も調査し、実装コストを下げつつPDF生成機能を実装する方法を調査しました。

結果、フロントで実装する場合

  • PDF生成用の信頼性の高いライブラリが存在する
  • ライブラリを活用すればバックエンドよりは実装コストが抑えられる
  • フロントエンドでのPDF生成であれば、サーバーサイドに新しい仕組みを構築する必要がなく、Reactのエコシステム内でコンポーネントベースに実装できる

ということがわかったため、今回はフロントで実装することになりました。

検討した2つの方法

フロントエンドでPDFを生成する方法として以下の2通りが候補に挙がりました。

方法①:テキスト + CSS で出力する(@react-pdf/renderer)

@react-pdf/renderer は、ReactコンポーネントのようにPDFドキュメントを宣言的に構築できるライブラリです。<Document>, <Page>, <View>, <Text>といった独自のプリミティブコンポーネントを使ってPDFのレイアウトを定義し、テキストベースのPDFを生成します。

メリット

  • テキスト選択・検索が可能
  • ファイルサイズが小さい
  • PDFが一定の長さになると自動でページが分割される

デメリット

  • PDF用コンポーネントを独自に実装する必要がある
  • @react-pdf/renderer は独自のプリミティブコンポーネントでPDFを構築するため、通常のコンポーネントの流用ができない

方法②:画像で出力する(html2canvas + jsPDF)

html2canvas でDOMをキャンバスに描画し、その画像を jsPDF でPDFに変換する方法です。画面に表示されているコンポーネントをそのままスクリーンショットのようにPDF化できます。

メリット

  • 既存のコンポーネントをそのまま流用できる
  • 画面をそのままPDF化できる

デメリット

  • 出力が画像になるため、テキストの選択や検索ができない
  • ファイルサイズが大きい
  • 求人票が縦長の場合でも1つの画像に収まってしまう

採用した方法とその理由

今回は 方法①のテキスト + CSS(@react-pdf/renderer) を採用しました。

両者を比較した結果、テキスト + CSS方式の方がメリットが大きいと判断したためです。

特に決め手となったのは、方法②のページ分割の問題です。画像方式ではコンテンツ全体が1つの画像として出力されるため、求人票のように縦長のコンテンツの場合、A4用紙に収めるために文字が縮小されて潰れてしまいます。

求人票は印刷して求職者に手渡しするユースケースもありうるのですが、これは印刷時に読みづらくなるため致命的でした。

一方、react-pdf/renderer はテキストベースのため自動でページ分割が行われ、この問題が発生しません。 求人票は内容によってはかなり長くなるものもあるため、この差は特に大きいです。

しかし、react-pdf/rendererのデメリットに挙げた

  • PDF用コンポーネントを独自に実装する必要がある
  • @react-pdf/renderer は独自のプリミティブコンポーネントでPDFを構築するため、通常のコンポーネントの流用ができない

の懸念は完全には解消できませんでした。

そのため、現在の課題として

  • 通常の求人表示用のコンポーネント
  • PDF用のコンポーネント

の2つが存在しており、求人情報の仕様変更があった場合は両方のコンポーネントを修正する必要があるというものがあります。

これは気になるデメリットではあるものの、他のメリット/デメリットや実装スケジュールも踏まえ妥協することにしました。

最終的な実装サンプル

ここからは、実際の実装サンプルを、注意したポイントを踏まえながら紹介します。

1.フォント登録

@react-pdf/renderer はデフォルトでは日本語に対応していません。日本語テキストを表示するためには、フォントファイルを用意して明示的に登録する必要があります。

今回は Noto Sans JP を使用しました。

import { Font } from '@react-pdf/renderer'
 
// react-pdfはデフォルトでは日本語に対応していないため、フォントを明示的に登録する必要がある
Font.register({
  family: 'NotoSansJP',
  src: '/fonts/NotoSansJP-Regular.otf',
  fontWeight: 'normal',
})
 
Font.register({
  family: 'NotoSansJP',
  src: '/fonts/NotoSansJP-Bold.otf',
  fontWeight: 'bold',
})
 
// react-pdf/renderer の自動ハイフネーションを回避する
Font.registerHyphenationCallback((word) =>
  Array.from(word).flatMap((char) => [char, '']),
)

ここで注意すべきポイントが1つあります。@react-pdf/renderer にはテキストの自動ハイフネーション機能が組み込まれていますが、この機能は英語を前提としているため、日本語テキストに対して意図しない位置で改行が入ってしまいます。

Font.registerHyphenationCallback で1文字ずつ分割するコールバックを登録することで、日本語テキストが自然な位置で折り返されるようになります。

2. PDFドキュメントコンポーネント

PDFの構造は、通常のReactコンポーネントと同じように宣言的に記述します。ただし、HTMLタグではなく @react-pdf/renderer が提供する独自のプリミティブを使用します。

import {
  Document,
  Page,
  Text,
  View,
  StyleSheet,
} from '@react-pdf/renderer'
import '../fonts'
 
const styles = StyleSheet.create({
  page: {
    padding: 32,
    fontFamily: 'NotoSansJP',
    fontSize: 10,
    lineHeight: 1.6,
    backgroundColor: '#F4F4F5',
  },
  section: {
    marginBottom: 16,
    backgroundColor: '#FFFFFF',
    borderRadius: 4,
  },
  sectionContent: {
    padding: 24,
  },
  sectionTitle: {
    fontSize: 14,
    fontWeight: 'bold',
    marginBottom: 12,
  },
  bodyText: {
    fontSize: 10,
    lineHeight: 1.6,
  },
})
 
type Props = {
  title: string
  description: string
}
 
export const PdfDocument = ({ title, description }: Props) => {
  return (
    <Document>
      <Page size="A4" orientation="portrait" style={styles.page}>
        <View style={styles.section}>
          <View style={styles.sectionContent}>
            <Text style={styles.sectionTitle}>{title}</Text>
            <Text style={styles.bodyText}>
              {sanitizeTextForPdf(description)}
            </Text>
          </View>
        </View>
 
        {/* 以下、必要なセクションを追加 */}
      </Page>
    </Document>
  )
}

通常のReact開発ではdivタグやpタグを使いますが、@react-pdf/renderer では (レイアウト用)と (テキスト表示用)が基本のプリミティブとなります。スタイリングも StyleSheet.create で定義し、CSSに似た記法で指定します。

3. 絵文字のサニタイズ処理

@react-pdf/renderer でPDFを生成する際、テキストに絵文字が含まれていると文字化けが発生します。これはNoto Sans JPフォントが絵文字をサポートしていないためです。

しかし今回は★や♪などNoto Sans JPでサポートされている装飾記号は残して欲しいという要望がありました。

そこで、PDF出力前にテキストから絵文字を除去し、ホワイトリスト方式で保護する関数を実装しました。

/**
 * Noto Sans JPフォントで表示可能な装飾記号のホワイトリスト
 */
const NOTO_SANS_JP_SUPPORTED_SYMBOLS =
  /[★☆●○◎◯■□◆◇▲△▼▽♠♣♥♦←↑→↓※「」『』【】♪♫♬♩☀☁☂☃✓✂]/g
 
/**
 * 絵文字を検出する正規表現
 */
const EMOJI_REGEX =
  /\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{RI}|\uFE0F|\u200D/gu
 
/**
 * PDF出力用にテキストをサニタイズする
 *
 * 絵文字を削除し、Noto Sans JPでサポートされている装飾記号は保持します。
 */
export const sanitizeTextForPdf = (text: string | null | undefined): string => {
  if (!text) return ''
 
  // サポートされている記号を一時的に保護
  const protectedChars = new Map<string, string>()
  let protectedText = text
 
  protectedText = protectedText.replace(
    NOTO_SANS_JP_SUPPORTED_SYMBOLS,
    (match) => {
      const placeholder = `__PRESERVE_${protectedChars.size}__`
      protectedChars.set(placeholder, match)
      return placeholder
    },
  )
 
  // 絵文字を削除
  const cleaned = protectedText.replace(EMOJI_REGEX, '')
 
  // 保護した記号を復元
  const result = cleaned.replace(/__PRESERVE_(\d+)__/g, (match) => {
    return protectedChars.get(match) ?? match
  })
 
  return result
}

処理の流れは以下のとおりです。

  1. Noto Sans JPでサポートされている装飾記号をプレースホルダーに置き換えて保護
  2. Unicode Property Escape(\p{Extended_Pictographic})を使用して絵文字を検出・削除
  3. 保護したプレースホルダーを元の記号に復元

これにより、「音楽♪が好き😊」というテキストは「音楽♪が好き」となり、装飾記号は残しつつ絵文字のみを除去できます。

4. PDFダウンロード処理

最後に、PDFの生成とダウンロードを行うカスタムフックです。

@react-pdf/renderer の pdf() 関数にReactコンポーネントを渡し、toBlob() でBlobに変換してダウンロードを実行します。

import { useCallback, useState } from 'react'
import { pdf } from '@react-pdf/renderer'
import { PdfDocument } from '../components/PdfDocument'
 
export const useDownloadPdf = () => {
  const [isDownloading, setIsDownloading] = useState(false)
 
  const downloadPdf = useCallback(
    async (data: { title: string; description: string; fileName: string }) => {
      setIsDownloading(true)
      try {
        // PDFドキュメントをBlobに変換
        const blob = await pdf(
          <PdfDocument title={data.title} description={data.description} />,
        ).toBlob()
 
        // ダウンロードを実行
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = `${data.fileName}.pdf`
        a.click()
        URL.revokeObjectURL(url)
      } catch (_error) {
        // エラーハンドリング
        console.error('PDFのダウンロードに失敗しました。')
      } finally {
        setIsDownloading(false)
      }
    },
    [],
  )
 
  return { downloadPdf, isDownloading }
}

まとめ

ReactでPDFダウンロード機能を実装する際、今回は以下の2つの方法を比較検討しました。

@react-pdf/renderer html2canvas + jsPDF
テキスト選択・検索 可能 不可
既存UIコンポーネントの流用 不可(独自プリミティブが必要) 可能
自動ページ分割 あり なし
ファイルサイズ 小さい 大きい

今回は テキスト + CSS(@react-pdf/renderer) を採用しました。社内UIライブラリが使えないデメリットはありますが、テキスト検索の可否、自動ページ分割、ファイルサイズの面で総合的に優れていると判断しました。特に、画像方式での「縦長コンテンツが1ページに縮小されて読めなくなる」問題は致命的であり、これが最終的な決定打となりました。

日本語環境で @react-pdf/renderer を使う場合は、フォントの明示的な登録、自動ハイフネーションの回避、絵文字のサニタイズといった対応が必要になる点も押さえておくと良いでしょう。

ReactでのPDF生成を検討されている方の参考になれば幸いです。

最後に

TalentXでは現在新しい仲間を募集中です! talentx.brandmedia.i-myrefer.jp

カジュアル面談も実施しておりますので、ぜひお気軽にご応募ください!
i-myrefer.jp