pdfmeをプロジェクトで使ってみた
PDF生成ライブラリである pdfme をプロジェクトで使ってみたので、使い方やハマったことなどをまとめました。
pdfme とは
pdfme は Kyohei さん(@labelmake)によって開発されており、作者自身による紹介記事も公開されています。
PDF生成ライブラリとしては「テンプレート」と「入力データ」の2つを渡して、様々なPDFが生成可能になるという仕組みです。
テンプレートも入力データもともにJSONおよびプレーンなJavaScriptオブジェクトなので、プログラムでの処理やテンプレートのバージョン管理など、TypeScriptエコシステムとの相性がよく、柔軟に利用できます。
また大きな特徴として、テンプレートの作成やプレビュー、入力データの編集を行うためのUIコンポーネントが提供されている点があります。
このうち「デザイナー」は、PDFのレイアウトをWYSIWYGに作成可能なGUIツールであり、PowerPoint、Googleスライド、Cacooなどの身近なツールと同じような感覚で直感的に複雑な帳票レイアウトを組むことができます。
UIにはデザイナー、フォーム、ビューワーという3つが用意されています。
- デザイナー:PDF のレイアウトを WYSIWYG に作成可能なエディター
 - フォーム:スキーマの入力を GUI で行うツール
 - ビューワー:PDF のプレビューを行うためのリードオンリーなフォーム
 
PDFの生成関数は generate です。
他ライブラリとの比較と選定理由
当初は pdfmake という別のライブラリを利用していました。こちらも素晴らしいライブラリですが、レイアウトの微調整をプログラム的に行う処理が必要であり、様々なパターンの帳票を取り扱いたくなった時に大変になったため、乗り換えを検討することにしました。
Node.js で利用可能なPDFライブラリには様々なものがあります。以下は一例です。
選定においては以下の要件を必須としました。
- 日本語フォントが利用できること
 - レイアウト調整がやりやすいこと
 - 画像の埋め込みができること
 - Node.js および ブラウザで動作すること
 
それに加えて、開発が活発であったり、一定の人気があることなども加味しましたが、やはり最大の決め手はデザイナーによる帳票レイアウトの組みやすさになります。
また pdfme はプラグインシステムにより、コア部分のシンプルさを保ちながら機能を独自に拡張できる設計になっており、非常に使いやすいライブラリだと感じています。「帳票作成をコストもかからずパッと終わらせられる未来」という作者のビジョンにも共感しました。
環境
以下のバージョンを使用しています。
- Node v22.11.0
 - pdfme 5.4.2
 
インストールと基本的な使い方
インストール
PDF 生成を行う場合は、以下のパッケージをインストールします。
npm i @pdfme/generator @pdfme/common
デザイナー、フォーム、ビューワーを使う場合は、以下も追加でインストールします。
npm i @pdfme/ui @pdfme/common
使い方
TypeScriptでのPDF生成の基本的なサンプルは次のようになります。
import type { Template } from '@pdfme/common';
import { text } from '@pdfme/schemas';
import { generate } from '@pdfme/generator';
const template: Template = {
  basePdf: { width: 297, height: 210, padding: [10, 10, 10, 10] },
  schemas: [
    [
      {
        name: 'a',
        type: 'text',
        content: 'Hello, ',
        position: { x: 10, y: 10 },
        width: 30,
        height: 10,
        readOnly: true,
        fontSize: 16
      },
      {
        name: 'b',
        type: 'text',
        position: { x: 10, y: 20 },
        width: 30,
        height: 10,
        fontSize: 16
      }
    ]
  ]
};
const plugins = {
  Text: text
};
const inputs = [
  {
    b: 'World!'
  }
];
const generatePdf = async () => {
  const pdf = await generate({ template, inputs, plugins });
  const blob = new Blob([pdf.buffer], { type: 'application/pdf' });
  window.open(URL.createObjectURL(blob));
};
PDFのレイアウトを構成する一つ一つの要素は schemas に定義されます。
上記の例では文字列を表示するための text しか使っていませんが、text 以外にも線(line)、四角形 (rectangle)、画像(image)、テーブル(table)など、帳票を組むために使いたくなるスキーマは標準として用意されています。
実行すると以下のようなPDFが生成されます。

テンプレートに関しては、サンプルギャラリーが公開されています。サンプルのテンプレートを見て、作り方を見極めていくのがよいでしょう。
1つのPDFファイルで複数ページを作るには?
1つのテンプレートから1つのPDFファイルで複数ページを作る場合、generate の inputs 引数が配列になっているため、各配列要素にそれぞれの入力を渡せば作ることができます。
項目の重なり順は?
各スキーマ項目には重なり順を制御するようなプロパティはありませんが、入力項目一覧の定義順に重ねられていくシンプルな仕様なようです。
項目の並びはデザイナーの右側のパネルから簡単にドラッグ&ドロップで入れ替えができます。

入力データに関係なく固定のテキストや画像を表示するには?
スキーマが "readonly": true (デザイナー上では編集可能のチェックがOFF)になっていると、テンプレートで設定されている値がそのまま表示されます。

一方で、入力データに応じて出力する内容を変更したい項目の場合は、編集可能に設定します。
日本語フォントを使うには?
pdfmeはデフォルトでRoboto Regular 400フォントを使用しますが、日本語は含まれていません。
カスタムフォントを利用して、日本語ファイルをインポートします。
安全な変換・出力のために
実際にPDF出力を行って遭遇したエラーや対処方法をTipsとしてまとめました。
基本的には文字列を渡す
generate 関数の inputs に渡す入力値は基本的に文字列で渡す必要があります。数値などをそのまま渡すと、以下のようなエラーが発生することがあります。
TypeError: raw.split is not a function
    at new Cell (chunk-ADKHCLVU.js?v=413ebe9b:65751:21)
    at chunk-ADKHCLVU.js?v=413ebe9b:66120:24
    at Array.map (<anonymous>)
フォントは相対パスでフェッチされない
httpで始まるstringを登録すると、自動的にフェッチされます。または、Uint8Array | ArrayBufferのようなバイナリデータを直接設定します。
フォントの読み込みには相対パスは使えません。https の URL を指定するか、fetch して arraybuffer を渡す必要があります。
const font = {
  myfont: {
    data: `http://${location.host}/path/to/myfont.ttf`,
    fallback: true
  }
};
const font = {
  myfont: {
    data: '/path/to/myfont.ttf',
    fallback: true
  }
};
DocumentExportDownloadPanel.vue:52 Error: Unknown font format
    at $d636bc798e7178db$export$185802fd694ee1f5 (chunk-ADKHCLVU.js?v=413ebe9b:25201:9)
    at getFontKitFont (chunk-ADKHCLVU.js?v=413ebe9b:37524:23)
    at getFontKitFontByFontName (chunk-ADKHCLVU.js?v=413ebe9b:65923:50)
相対パスを使用したい場合、事前にフェッチしたバイナリデータを渡す方法があります。
const fontData = await fetch('/path/to/myfont.ttf').then(res => res.arrayBuffer());
const font = {
  myfont: {
    data: fontData,
    fallback: true
  }
};
テーブルスキーマの配列の要素数は必ず揃える
動的テーブルを使う場合、スキーマとして定義されたテーブルの列数と inputs で渡す入力データの行単位の要素数は一致している必要があります。
TypeError: Cannot read properties of undefined (reading 'split')
    at new Cell (chunk-ADKHCLVU.js?v=413ebe9b:65751:21)
    at chunk-ADKHCLVU.js?v=413ebe9b:66120:24
    at Array.map (<anonymous>)
Vue で使う場合、template が Proxy の場合は toRaw で unwrap する
pdfmeのUI(デザイナー/フォーム/ビューワー)の開発にはReactが使われていますが、Vueのアプリケーションの上にUIを構成することもできます。
Vue3のReactiveシステムを使っている場合、テンプレートを渡す際に toRaw で Proxy をアンラップする必要があることがあります。
例えば下記のコードではPDF出力の際にエラーが起きる場合があります。
import { ref } from 'vue';
import { generate } from '@pdfme/generator';
const template = ref<Template | null>(null);
const inputs = { ... };
const options = { ... };
const plugins = { ... };
const pdf = await generate({ template: template.value!, inputs, options, plugins });
DataCloneError: Failed to execute 'structuredClone' on 'Window': #<Object> could not be cloned.
toRaw で以下のようにアンラップすることで、このエラーを回避できます。
- import { ref } from 'vue';
+ import { ref, toRaw } from 'vue';
- const pdf = await generate({ template: template.value!, inputs, options, plugins });
+ const pdf = await generate({ template: toRaw(template.value!), inputs, options, plugins });
Discussion