Loading...

2023-09-18(月) 15:00

📑 Next.js で PDFへの書き込みと出力機能を実装する

Next.jsPDF
Next.jsでpdf-libというパッケージを使用し、PDFファイルに任意の文字列を書き込んで出力する機能を実装する方法について解説します。

目次

前提

この記事では以下を前提としています。

  • Next.js のバージョンは 13.4.19 (App router 使用)
  • Typescript 使用
  • pdf-libを使用
  • Tailwind を使用
  • 本番環境に必要なエラーハンドリングやバリデーションなどは実装していない

この記事のゴール

以下のように、「PDF ダウンロード」というボタンをクリックすると、指定した PDF ファイルをテンプレートとして、データを追記した PDF ファイルがダウンロードされます。 テンプレートとして使用した PDF ファイルは、A4 サイズでページ中央に「Sample PDF」と書かれているものです。 そこに「日本語 書き込み テスト PDF」という適当な文字列を書き込み、ダウンロードしています。

Next.jsでpdf-libを使ってPDFへの書き込み、出力する例

すなわち、以下の機能の実装について解説します。

  • 好きな PDF ファイルをテンプレートとして使用して、その PDF ファイルに任意のデータを書き込む
  • テンプレートにデータを書き込んだ PDF ファイルを生成し、ダウンロードする

pdf-lib をインストールする

PDF ファイルにデータを書き込むために、pdf-libを使用します。pdf-libでは PDF ファイルの新規作成、データの書き込み、ページ追加や削除など色々な PDF 操作が可能なパッケージになります。 また、日本語フォントを扱うために@pdf-lib/fontkitも一緒にインストールします。

ターミナル
# npmを使う場合
npm install --save pdf-lib @pdf-lib/fontkit
 
# yarnを使う場合
yarn add pdf-lib @pdf-lib/fontkit

pdf-libの公式リポジトリは以下です。

pdf-lib

pdf-libのGitHubリポジトリ

github.com

@pdf-lib/fontkitの公式リポジトリは以下です。

@pdf-lib/fontkit

@pdf-lib/fontkitのGitHubリポジトリ

github.com

使用する日本語フォントを用意する

ここでは、商用利用も可能な日本語フォントとして以下の IPA フォントを使用します。

フォントファイルをダウンロードし、Next.js のpublic/fontsに配置します。fontsディレクトリはデフォルトでは用意されていないため、必要に応じて作成してください。 public直下にfontsディレクトリを作成し、その中にipaexm.ttfを配置した場合は以下のようになります。

./public
public
├── fonts
│   └── ipaexm.ttf

特にフォントの配置場所については決まりはないので、必要に応じて適切な場所に配置してください。

PDF ファイルを出力用のボタンを作成する

PDF ファイル出力用のボタンのコンポーネントPdfPrintButton.tsxを以下のようにします。

PdfPrintButton.tsx
'use client';
import { PDFDocument } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
 
export default function PdfPrintButton() {
  const pdfPrint = async () => {
    // Create a new PDFDocument
    const pdfDoc = await PDFDocument.create();
 
    // テンプレートとして使用したいPDFを読み込む
    const url = '/a4_sample.pdf';
 
    const arrayBuffer = await fetch(url).then((res) => res.arrayBuffer());
    const doc = await PDFDocument.load(arrayBuffer);
 
    // fontの読み込みと埋め込み
    pdfDoc.registerFontkit(fontkit);
    const fontRaw = await fetch('/fonts/ipaexm.ttf').then((res) =>
      res.arrayBuffer(),
    );
    const fontIpamp = await pdfDoc.embedFont(new Uint8Array(fontRaw), {
      subset: true,
    });
 
    // テンプレートファイルの1ページ目をコピーして、新しいページを作成する
    const [_templatePage] = await pdfDoc.copyPages(doc, [0]);
    const _page1 = pdfDoc.addPage(_templatePage);
 
    // 書き込む文字列の位置、サイズ設定
    const targetText = '日本語 書き込み テスト PDF';
    const targetTextFontSize = 18;
    const targetTextXPosition = 200;
    const targetTextYPosition = 800;
 
    // 書き込む文字列の位置、サイズ、フォント、色を指定して書き込む
    // 日本語を書き込む場合にはフォントの設定が必要
    _page1.drawText(targetText, {
      x: targetTextXPosition,
      y: targetTextYPosition,
      size: targetTextFontSize,
      font: fontIpamp,
      // color: rgb(0, 0, 0)
    });
 
    const dataUri = await pdfDoc.saveAsBase64({
      dataUri: true,
      addDefaultPage: false,
    });
 
    // 書き込みしたPDFファイルをダウンロードするためのリンクを作成し、
    // そのリンクをクリックすることでダウンロードできるようにする
    const link = document.createElement('a');
    link.href = dataUri;
    link.download = 'サンプル.pdf';
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };
 
  return (
    <>
      <button
        className='bg-blue-500 hover:bg-blue-700 text-white text-sm font-bold py-1 px-2 rounded bg-blue-500 hover:bg-blue-700 mr-2'
        onClick={() => pdfPrint()}
      >
        <span>PDFダウンロード</span>
      </button>
    </>
  );
}

上記のPdfPrintButtonを、例えば以下のように適当なページの中で読み込んで使用します。

index.tsx
import PdfPrintButton from './components/PdfPrintButton';
 
export default function Home() {
  return (
    <main className='flex min-h-screen flex-col items-center justify-between p-24'>
      <div className='z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex'>
        <PdfPrintButton />
      </div>
    </main>
  );
}

あとは以下でサーバーを起動して、PDFダウンロードボタンをクリックすると、冒頭に載せた GIF 画像と同じようにサンプル.pdfというファイルがダウンロードされます。

ターミナル
yarn dev

日本語の取り扱いについて

pdf-libを使用して PDF ファイルに日本語を書き込みたい場合は、日本語フォントを読み込んで埋め込む必要があります。 日本語を書き込む際に日本語フォントが指定されていない場合は、ダウンロードボタンをクリックした時にブラウザ側に以下のようなエラーが出ます。

ターミナル
Uncaught (in promise) Error: WinAnsi cannot encode "テ" (0x30c6)

上記に載せたPdfPrintButton.tsxの中で日本語フォントの読み込みと埋め込み部分は以下になります。 サブセット化に対応しているフォントファイルであれば、以下のようにsubset: trueを指定することでサブセット化することができます。

PdfPrintButton.tsx
'use client';
import { PDFDocument } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
 
export default function PdfPrintButton() {
  const pdfPrint = async () => {
 
    (...省略...)
 
    // fontの読み込みと埋め込み
    pdfDoc.registerFontkit(fontkit);
    const fontRaw = await fetch('/fonts/ipaexm.ttf').then((res) =>
      res.arrayBuffer(),
    );
    const fontIpamp = await pdfDoc.embedFont(new Uint8Array(fontRaw), {
      subset: true,
    });
 
    (...省略...)
 
  };
 

なお、日本語ファイルの設置などは使用する日本語フォントを用意するの通りです。

書き込み位置の調整について

drawTextで指定するx(水平位置)およびy(縦位置)の値が実際にどこに書き込まれるかは、テンプレートとして使用するファイルの解像度に依存します。 例えば、今回使用したa4_sample.pdfファイルの解像度は595 × 841です。(解像度はファイル上で右クリックして「情報」のような項目から確認できると思います。) pdf-libは、ファイルの左下を基点(xが 0、yが 0)とするため、a4_sample.pdf の上部に書き込みたい場合は以下のように指定します。

PdfPrintButton.tsx
_page1.drawText('TEST PDF', {
  x: 290,
  y: 830,
  size: 18,
  // font: fontIpamp,
  // color: rgb(0, 0, 0)
});

上記のdrawTextでは、a4_sample.pdfの左下から 290px 右、830px 上の位置にTEST PDFと書き込みます。

中央揃えで書き込む

drawTextのxやyは文字列の書き始め位置を指定するため、文字列の長さに応じて中央揃えしたい場合は以下のようにします。

PdfPrintButton.tsx
 
(...省略...)
 
// fontの読み込みと埋め込み
pdfDoc.registerFontkit(fontkit);
const fontRaw = await fetch('/fonts/ipaexm.ttf').then((res) =>
  res.arrayBuffer(),
);
const fontIpamp = await pdfDoc.embedFont(new Uint8Array(fontRaw), {
  subset: true,
});
 
(...省略...)
 
const targetText = '中央揃えテスト';
const targetTextFontSize = 18;
const targetTextYPosition = 800;
 
// 使用するフォントでの文字列の長さを取得
const targetTextWidth = fontIpamp.widthOfTextAtSize(
  targetText,
  targetTextFontSize,
);
 
// 中央揃えするためのxの座標を計算
const targetTextXPosition = page.getWidth() / 2 - targetTextWidth / 2;
 
_page1.drawText(targetText, {
  x: targetTextXPosition,
  y: targetTextYPosition,
  size: targetTextFontSize,
  font: fontIpamp,
  // color: rgb(0, 0, 0)
});

上記は、フォントのサイズを指定して文字列の長さtargetTextWidthを取得し、中央揃えしたい要素の幅(上記では、ページの幅)の半分から文字列の長さの半分を引いた値をxとして指定しています。 これにより文字列の長さによらずページ中央に文字列が書きこむことができます。

もしページ中央ではなく、特定の要素の中で中央揃えしたい場合は、その要素の幅を指定するなど調整すれば可能です。

遭遇したエラー

Error: Failed to parse PDF document (line:0 col:xxxx offset=xxxx): No PDF header found

これは、読み込もうとしていたテンプレートとして使用するための PDF ファイルが存在しなかった場合に発生しました。 ブラウザのコンソールに表示されます。 具体的には、以下のurlが間違っていたために PDF を読み込めない状態でした。適切な URL に変更することで解消できました。

pdfPrint.ts
const pdfDoc = await PDFDocument.create();
 
// テンプレートとして使用したいPDFを読み込む
const url = '/a4_sample.pdf';
 
// 以下のように環境変数を使っている場合はその環境変数の値が間違っている可能性もあるかもしれません
// const url = `${process.env.NEXT_PUBLIC_URL}/a4_sample.pdf`;
 
const arrayBuffer = await fetch(url).then((res) => res.arrayBuffer());
const doc = await PDFDocument.load(arrayBuffer);

まとめ

pdf-libを使用することで、好きな PDF ファイルをテンプレートとしてデータを書き込むことができます。 この記事では1つのテンプレートファイルに文字列を書き込んで1つの新しい PDF ファイルをダウンロードするのみの簡単な例ですが、 あとはデータ一覧から繰り返し処理したり、複数のテンプレートにデータを書き込んだり、色々なことができると思います。