kintoneで「見積書をPDFで出したい」という要望は、帳票まわりでおそらく最も多いものです。多くの場合は帳票プラグインを導入するのが正解ですが、要件がシンプルで、ランニングコストをかけたくない、あるいはレイアウトを完全に自前で制御したいというケースでは、JavaScriptカスタマイズで作ってしまうという選択肢があります。

今回は、オープンソースのPDF生成ライブラリ pdfmake を使って、レコード詳細画面に「見積書PDF出力」ボタンを追加し、日本語の見積書PDFをブラウザ上だけで生成するところまで実際に作ってみます。サーバーは一切立てません。

■なぜkintoneは「見積書をPDFで」がつまずくのか

まず前提の整理です。kintoneには自社フォーマットの帳票を出力する機能が標準では存在しません。

「レコード印刷はあるじゃないか」と思われるかもしれませんが、あれは画面レイアウトをそのまま紙に落とすだけの機能です。ロゴも押印欄も入らず、明細行の体裁も整わず、複数レコードをまとめて出すこともできません。社内の控えとしては使えても、取引先に送れる見た目にはならないというのが実際のところです。

そのため実務では、次の3つのいずれかを選ぶことになります。

正直に書いておくと、業務の中心が帳票発行であるなら、素直に帳票サービスを使うべきです。保守を自社で抱え込まずに済み、インボイス制度や電子帳簿保存法への追随もベンダー側が対応してくれます。

一方で「見積書1種類だけ」「社内で完結する」「レイアウトはこちらで決めたい」という条件なら、自作は十分に現実的です。今回はその前提で進めます。

■完成イメージ ― ボタンひとつで、日本語の見積書PDF

先にゴールをお見せします。レコード詳細画面のヘッダーに追加されたボタンを押すと、その場でPDFが生成され、モーダルでプレビューが開きます。

◆ 出力(生成された見積書PDF):

動作の流れは4ステップです。

■構成 ― pdfmake本体+日本語フォントVFS+カスタマイズ本体

仕組みは3つのファイルでできています。アプリにアップロードするのはこれだけです。

pdfmake(npm)は、JavaScriptのオブジェクトで帳票レイアウトを宣言的に記述してPDFを生成するライブラリです。ライセンスはMITで商用利用も可能です。

kintoneカスタマイズと相性が良い理由は3点あります。

■サンプルアプリのフィールド構成

検証に使ったアプリのフィールドは次のとおりです。ラベルではなくフィールドコードがコードと対応する点に注意してください。

ここでの設計上のポイントは、金額計算をすべてkintoneの計算フィールド側に寄せていることです。小計・消費税額・合計をJavaScript側で再計算すると、画面の表示金額とPDFの金額がズレるリスクが生まれます。計算はkintoneに任せ、JavaScriptは「読んで並べるだけ」にしておくのが安全です。

■日本語フォントをVFSに組み込む

pdfmakeの配布物に同梱されている標準フォントは Roboto(欧文のみ) です。そのまま日本語を出力すると、文字化けするか、何も表示されない空白のPDFになります。

日本語を出すには、日本語フォントをBase64に変換し、pdfmakeの仮想ファイルシステム(VFS)に組み込む必要があります。手順は3ステップです。

1. フォントを入手する

日本語サブセット版のOTFを Noto CJK リポジトリ(Sans/SubsetOTF/JP) から取得します。使うのは Regular と Bold の2ファイルです。ライセンスは SIL Open Font License 1.1 で、商用利用は可能ですがライセンス表記が必要になります。

2. VFSを生成する

npmでpdfmakeを取得し、フォントを配置したディレクトリを引数に、同梱のVFSビルドスクリプト(build-vfs.js)を実行します。生成された vfs_fonts.js が、Base64化したフォントをpdfmakeへ登録するファイルになります。詳しい仕様は pdfmake 公式ドキュメント「Custom fonts via Virtual file system」 に記載があります。

3. フォントファミリーを登録する

カスタマイズ側で、VFSに含まれるファイル名を指定してフォントを登録します。normal に Regular、bold に Bold を割り当て、italics と bolditalics にも同じファイルを指定しておけば、斜体指定が混ざっても落ちません。

そして帳票定義の defaultStyle で font を「NotoSansJP」に指定します。この一行を忘れると日本語は出ません。筆者はここで一度つまずきました。フォントをVFSに入れただけでは、pdfmakeは既定のRobotoを使い続けます。

なお日本語フォントはサブセットでも、Regular/Boldの2書体で数MB規模になります。kintoneヘルプ「制限値一覧」のとおり、カスタマイズ用JavaScriptは1ファイル20MBまで、1アプリ30ファイルまでなので容量上の問題はありません。ただし初回のページ読み込みは体感で重くなります。太字が不要ならRegularのみに絞ると軽量化できます。

■コードの全体像 ― 4つのブロックでできている

ここからカスタマイズ本体(report.js)の全文を掲載します。全体で約150行ですが、長さに身構える必要はありません。やっていることは「ボタンを置く」「レコードを読む」「レイアウトを組む」「表示する」の4つだけで、残りは数値や日付を整形する小さな関数です。

帳票の見た目を変えたいときに触るのは③だけです。①②④は基本的にそのまま使い回せます。

■① イベント登録とボタン生成

まずは入り口です。全体を即時実行関数で包み、グローバルスコープを汚さないようにしています。

(() => {
  'use strict';

  const BUTTON_ID = 'pdfmake-estimate-output-button';
  const FONT_NAME = 'NotoSansJP';
  const REQUIRED_FIELDS = [
    'customer_name', 'estimate_number', 'issue_date', 'issuer_name',
    'items', 'subtotal', 'tax_rate', 'tax_amount', 'total'
  ];

  kintone.events.on('app.record.detail.show', (event) => {
    if (document.getElementById(BUTTON_ID)) return event;

    const headerSpace = kintone.app.record.getHeaderMenuSpaceElement();
    if (!headerSpace) return event;

    const button = document.createElement('button');
    button.id = BUTTON_ID;
    button.type = 'button';
    button.textContent = '見積書PDF出力';
    button.className = 'kintoneplugin-button-normal';
    button.style.marginLeft = '8px';

    button.addEventListener('click', () => {
      button.disabled = true;
      try {
        if (!window.pdfMake || typeof window.pdfMake.createPdf !== 'function') {
          throw new Error('pdfmakeが読み込まれていません。JavaScriptの読み込み順を確認してください。');
        }

        window.pdfMake.addFonts({
          [FONT_NAME]: {
            normal:      'NotoSansJP-Regular.otf',
            bold:        'NotoSansJP-Bold.otf',
            italics:     'NotoSansJP-Regular.otf',
            bolditalics: 'NotoSansJP-Bold.otf'
          }
        });

        validateFields(event.record);

        const values = resolveValues(event.record);
        const fileName = `見積書_${safeName(values.customer)}_${safeName(values.number)}.pdf`;

        window.pdfMake.createPdf(buildDocument(event.record, values))
          .getDataUrl((dataUrl) => showPreview(dataUrl, fileName));
      } catch (error) {
        console.error('[pdfmake estimate sample]', error);
        alert(`PDFの生成に失敗しました。\n${error.message}`);
      } finally {
        button.disabled = false;
      }
    });

    headerSpace.appendChild(button);
    return event;
  });

ここで押さえてほしい点が4つあります。

1. 冒頭のID重複チェックは省略できません。kintoneは画面遷移の仕方によって同じイベントが複数回発火することがあり、このチェックがないとボタンが2つ3つと増えていきます。実際、最初の実装ではこれで増殖しました。

2. ボタンにはkintone標準のクラスを当てます。「kintoneplugin-button-normal」を指定すると、自作ボタンでも標準機能と同じ見た目になり、後から見た人が違和感を持ちません。

3. pdfmakeの存在確認を最初に行います。読み込み順を間違えた場合、ここで原因が特定できるエラーメッセージが出ます。「undefined のプロパティを読めません」という汎用エラーで悩む時間を減らせます。

4. try / catch / finally で必ずボタンを戻します。処理中はボタンを無効化して二重クリックを防ぎ、成功・失敗にかかわらず finally で元に戻します。エラーはコンソールに詳細を出し、利用者にはメッセージだけを見せる形です。

■② 検証と値の取り出し

次は、レコードから必要な値を集めるブロックです。

  function validateFields(record) {
    const missing = REQUIRED_FIELDS.filter((code) => !record[code]);
    if (missing.length) throw new Error(`必要なフィールドがありません:${missing.join(', ')}`);
  }

  function resolveValues(record) {
    return {
      customer:      valueOf(record.customer_name, '顧客名未入力'),
      number:        valueOf(record.estimate_number, '番号なし'),
      total:         numberOf(record.total, 0),
      subject:       valueOf(record.subject, ''),
      issueDate:     formatDate(valueOf(record.issue_date, '')),
      validUntil:    formatDate(valueOf(record.valid_until, '')),
      issuerName:    valueOf(record.issuer_name, ''),
      issuerAddress: valueOf(record.issuer_address, ''),
      issuerPhone:   valueOf(record.issuer_phone, ''),
      notes:         valueOf(record.notes, '')
    };
  }

  function getItems(record) {
    const rows = record.items && Array.isArray(record.items.value) ? record.items.value : [];
    return rows.map((row) => {
      const item = row.value || {};
      const quantity  = numberOf(item.quantity, 0);
      const unitPrice = numberOf(item.unit_price, 0);
      return {
        name: valueOf(item.item_name, ''),
        quantity,
        unitPrice,
        amount: numberOf(item.amount, quantity * unitPrice)
      };
    });
  }

validateFields は、あえて処理を止めるための関数です。フィールドコードが1つでも足りなければ、その場で例外を投げてPDFを生成しません。

ここは設計判断が分かれるところです。初期の実装では「フィールドが見つからなければ、最初の文字列フィールドで代用する」というフォールバックを入れていました。動作としては親切ですが、間違った項目が印字されたPDFが取引先に届くほうが、何も出ないより明らかに危険です。止まれば人が気づきますが、それらしい見た目で間違っている帳票には誰も気づきません。最終的にフォールバックは廃止し、不足しているフィールドコードを名指しでエラーに出す方式にしました。

getItems では、サブテーブルの二段構造に注意してください。明細テーブルは値が配列になっており、さらに各行の実データがもう一段下の階層に入っています。ここを見落とすと明細が空のまま出力されます。

■③ 帳票定義の組み立て ― ここが実質的なテンプレート

pdfmakeに渡すレイアウト定義です。見た目を変えたいときに触るのはこの関数だけなので、少し丁寧に読んでください。

  function buildDocument(record, values) {
    const items     = getItems(record);
    const subtotal  = numberOf(record.subtotal, 0);
    const taxRate   = numberOf(record.tax_rate, 10);
    const taxAmount = numberOf(record.tax_amount, 0);
    const total     = values.total;

    const body = [[
      header('品目'), header('数量', 'center'), header('単価', 'right'), header('金額', 'right')
    ]];

    if (items.length) {
      items.forEach((item) => body.push([
        cell(item.name || '(未入力)'),
        cell(formatNumber(item.quantity), 'center'),
        cell(`${formatCurrency(item.unitPrice)}円`, 'right'),
        cell(`${formatCurrency(item.amount)}円`, 'right')
      ]));
    } else {
      body.push([
        {text: '明細は登録されていません', colSpan: 4, color: '#666666', margin: [4, 8, 4, 8]},
        {}, {}, {}
      ]);
    }

    return {
      pageSize: 'A4',
      pageMargins: [42, 48, 42, 52],
      info: {title: `見積書 ${values.number}`, subject: values.subject, author: values.issuerName},
      content: [

        // タイトル
        {text: '御 見 積 書', style: 'title'},

        // 左に宛名、右に発行情報と発行元
        {
          columns: [
            {
              width: '*',
              stack: [
                {text: `${values.customer} 御中`, style: 'customer'},
                values.subject ? {text: `件名:${values.subject}`, margin: [0, 12, 0, 0]} : {}
              ]
            },
            {
              width: 210,
              stack: [
                {text: `見積番号:${values.number}`},
                {text: `発行日:${values.issueDate || '-'}`, margin: [0, 3, 0, 0]},
                values.validUntil ? {text: `有効期限:${values.validUntil}`, margin: [0, 3, 0, 0]} : {},
                {text: values.issuerName, bold: true, margin: [0, 14, 0, 0]},
                values.issuerAddress ? {text: values.issuerAddress, margin: [0, 2, 0, 0]} : {},
                values.issuerPhone ? {text: `TEL:${values.issuerPhone}`, margin: [0, 2, 0, 0]} : {}
              ]
            }
          ],
          columnGap: 24,
          margin: [0, 0, 0, 28]
        },

        // 合計金額の帯
        {
          table: {widths: ['*'], body: [[{
            text: `合計金額 ${formatCurrency(total)}円(税込)`,
            alignment: 'center', bold: true, fontSize: 17,
            color: '#17365d', fillColor: '#eaf2f8', margin: [8, 10, 8, 10]
          }]]},
          layout: {hLineColor: () => '#6c8ebf', vLineColor: () => '#6c8ebf'},
          margin: [0, 0, 0, 22]
        },

        // 明細テーブル
        {
          table: {headerRows: 1, widths: ['*', 55, 80, 85], body, dontBreakRows: true},
          layout: {
            fillColor: (rowIndex) => rowIndex === 0 ? '#d9e7f5' : null,
            hLineColor: () => '#a9b7c6',
            vLineColor: () => '#a9b7c6'
          }
        },

        // 小計・消費税・合計(右寄せ)
        {
          columns: [
            {width: '*', text: ''},
            {
              width: 235,
              table: {widths: ['*', 100], body: [
                summary('小計', subtotal),
                summary(`消費税(${formatNumber(taxRate)}%)`, taxAmount),
                summary('合計', total, true)
              ]},
              layout: {hLineColor: () => '#a9b7c6', vLineColor: () => '#a9b7c6'}
            }
          ],
          margin: [0, 14, 0, 0]
        },

        // 備考(入力があるときだけ出す)
        values.notes ? {
          stack: [
            {text: '備考', bold: true, margin: [0, 24, 0, 5]},
            {
              table: {widths: ['*'], body: [[{text: values.notes, margin: [6, 6, 6, 6]}]]},
              layout: {hLineColor: () => '#a9b7c6', vLineColor: () => '#a9b7c6'}
            }
          ]
        } : {}
      ],
      footer: (page, pages) => ({
        text: `${page} / ${pages}`, alignment: 'center',
        color: '#777777', fontSize: 8, margin: [0, 16, 0, 0]
      }),
      defaultStyle: {font: FONT_NAME, fontSize: 9.5, color: '#222222', lineHeight: 1.25},
      styles: {
        title:    {fontSize: 24, bold: true, alignment: 'center', characterSpacing: 4, margin: [0, 0, 0, 32]},
        customer: {fontSize: 14, bold: true, decoration: 'underline'}
      }
    };
  }

読み解くポイントを挙げます。

金額はkintoneの計算フィールドから読むだけ。小計・消費税額・合計はJavaScriptで再計算していません。第2引数のフォールバックはあくまで保険で、通常はkintoneが確定させた値がそのまま印字されます。画面とPDFで数字が食い違う事故は、この設計にしておけば構造的に起きません。

defaultStyle の font 指定が日本語表示の生命線です。ここで「NotoSansJP」を指定しないと、VFSにフォントを組み込んでいてもpdfmakeは既定のRobotoを使い続け、日本語がまるごと空白になります。

headerRows を1にすると、ページをまたいでもヘッダー行が繰り返されます。明細が20行を超えるような見積書では効いてきます。あわせて dontBreakRows を有効にしておくと、1行が途中で分断されるのを防げます。

空の値は空オブジェクトで飛ばします。件名・有効期限・備考などは、未入力なら要素そのものを出力しない書き方にしています。pdfmakeは空オブジェクトを無視するため、条件分岐を素直に三項演算子で書けます。

レイアウト調整の勘所。A4を横にしたければ pageSize の隣に pageOrientation を追加、余白を変えたければ pageMargins(左・上・右・下の順)、罫線の色は layout の hLineColor と vLineColor、明細の列幅は widths の配列を触ります。アスタリスクは「残りの幅を埋める」指定なので、品目欄だけ可変にしてあります。

■④ プレビュー表示とユーティリティ

生成したPDFを画面に出す部分と、細かい整形関数です。

  function showPreview(dataUrl, fileName) {
    const existing = document.getElementById('pdfmake-preview-overlay');
    if (existing) existing.remove();

    const overlay = document.createElement('div');
    overlay.id = 'pdfmake-preview-overlay';
    Object.assign(overlay.style, {
      position: 'fixed', inset: '20px', zIndex: '100000', background: '#fff',
      border: '1px solid #999', boxShadow: '0 4px 18px rgba(0,0,0,.35)',
      display: 'flex', flexDirection: 'column'
    });

    const toolbar = document.createElement('div');
    Object.assign(toolbar.style, {
      display: 'flex', alignItems: 'center', gap: '12px',
      padding: '10px 14px', background: '#f5f5f5', borderBottom: '1px solid #ccc'
    });

    const title = document.createElement('strong');
    title.textContent = '見積書PDFプレビュー';

    const save = document.createElement('a');
    save.href = dataUrl;
    save.download = fileName;
    save.textContent = 'PDFを保存';
    save.className = 'kintoneplugin-button-normal';

    const close = document.createElement('button');
    close.type = 'button';
    close.textContent = '閉じる';
    close.className = 'kintoneplugin-button-dialog-cancel';
    close.style.marginLeft = 'auto';
    close.addEventListener('click', () => overlay.remove());

    const frame = document.createElement('iframe');
    frame.title = '見積書PDFプレビュー';
    frame.src = dataUrl;
    Object.assign(frame.style, {width: '100%', flex: '1', border: '0'});

    toolbar.appendChild(title);
    toolbar.appendChild(save);
    toolbar.appendChild(close);
    overlay.appendChild(toolbar);
    overlay.appendChild(frame);
    document.body.appendChild(overlay);
  }

  function valueOf(field, fallback) {
    return !field || field.value === null || field.value === undefined || field.value === ''
      ? fallback : String(field.value);
  }

  function numberOf(field, fallback) {
    if (!field || field.value === null || field.value === undefined || field.value === '') return fallback;
    const number = Number(String(field.value).replace(/,/g, ''));
    return Number.isFinite(number) ? number : fallback;
  }

  function header(text, alignment) {
    return {text, bold: true, alignment: alignment || 'left', margin: [4, 5, 4, 5]};
  }

  function cell(text, alignment) {
    return {text: String(text), alignment: alignment || 'left', margin: [4, 5, 4, 5]};
  }

  function summary(label, amount, bold) {
    return [
      {text: label, bold: Boolean(bold), margin: [5, 4, 5, 4]},
      {text: `${formatCurrency(amount)}円`, bold: Boolean(bold), alignment: 'right', margin: [5, 4, 5, 4]}
    ];
  }

  function formatNumber(value) {
    return Number(value || 0).toLocaleString('ja-JP', {maximumFractionDigits: 4});
  }

  function formatCurrency(value) {
    return Math.round(Number(value || 0)).toLocaleString('ja-JP');
  }

  function formatDate(value) {
    const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
    return match ? `${match[1]}年${Number(match[2])}月${Number(match[3])}日` : String(value || '');
  }

  function safeName(value) {
    return String(value || '未入力').replace(/[\\/:*?"<>|]/g, '_').slice(0, 50);
  }
})();

ここも4点だけ補足します。

プレビューは新規タブではなくモーダルにしています。pdfmakeには新規タブで開くメソッドもありますが、ブラウザのポップアップブロックに引っかかることがあるため、Data URLをiframeに流し込む方式のほうが安定します。

numberOf ではカンマを除去しています。kintoneの数値フィールドは桁区切り設定によって「175,000」のような文字列で返ってくることがあり、そのままNumberに渡すとNaNになります。

formatDate は正規表現で分解するだけです。kintoneの日付フィールドは年月日をハイフンで区切った文字列で返ってくるので、「年」「月」「日」に組み替えれば日本の帳票の体裁になります。

safeName は地味ですが必須です。顧客名をファイル名に使う場合、Windowsで使用できない記号を除去しないとダウンロードが失敗します。取引先名にスラッシュが入るケースは実際にあります。

■導入手順 ― 3ファイルをkintoneに登録する

実際にアプリに組み込むます。

1. 対象アプリを開き、右上の歯車アイコンから「アプリの設定」へ進みます。

2. 「設定」タブの「JavaScript / CSSでカスタマイズ」を開きます。

3. 「PC用のJavaScriptファイル」で「ファイルを追加」を選び、次の順番でアップロードします。

4. 画面下部の「保存」を押します。

5. 続けて「アプリを更新」を実行します。この操作を忘れると設定が本番に反映されません。「保存」だけで満足して「動かない」と悩むのは、kintoneカスタマイズで最も多いつまずきです。

この順番は必須です。理由は依存関係にあります。vfs_fonts.js は読み込み時点でpdfmake本体がグローバルに存在していることを前提にVFSを登録し、report.js はpdfmakeとVFSの両方が揃っていることを前提に動きます。順番が違うと、「pdfmakeが読み込まれていません」というエラーになるか、日本語が空白のPDFが出力されます。カスタマイズ画面ではドラッグで並べ替えられるので、保存前に必ず確認してください。

アップロード方法にはREST APIやコマンドラインツールを使う選択肢もあります。運用に載せる段階では cybozu developer network「kintoneカスタマイズファイルのアップロード方法まとめ」 が参考になります。

なお今回のコードは、掲載したフィールドコードをそのまま使用します。既存アプリに適用する場合は、アプリ側のフィールドコードを合わせるか、REQUIRED_FIELDS と各参照名をアプリの構成に書き換えてください。

■動作確認

アプリを更新したら、レコード詳細画面を開いて確認して「見積書PDF出力」をクリックすると帳票が表示されます。

■まとめ ― 150行で、月額費用のかからない帳票ができる

今回のコードで確認できたことを整理します。

約150行のJavaScriptで、月額費用のかからない見積書PDFができました。目新しい技術はひとつも使っていません。kintoneがデータと計算を持ち、pdfmakeがそれを紙面に落とすという、それだけの構成です。

ただし前回も書いたとおり、安く作れることと、長く運用できることは別の話です。このコードは自社で保守することになります。帳票の種類が増える、様式変更が頻繁に発生する、法令要件が絡む ― そのいずれかに該当し始めたら、いったん立ち止まって「買う」選択肢に戻る。その判断ができることが、自作を選ぶうえでの前提だと考えています。

■参考情報(一次情報)

※本記事は2026年8月時点の情報をもとに作成しています。検証環境は pdfmake 0.2.23 と Noto Sans JP(SubsetOTF Regular・Bold)の組み合わせで、JavaScript構文チェック、日本語PDFの生成テスト、kintone上でのプレビュー表示まで確認済みです。掲載したコードは検証環境で動作したものですが、kintoneのバージョン・ブラウザ・ライブラリのバージョンによって動作が異なる場合があります。本番アプリへ適用する前に、必ず検証環境で動作を確認してください。記事中の顧客名・見積番号・金額はすべて架空のサンプルです。コードの利用は自己責任でお願いします。ライブラリおよびフォントのライセンス条件は変更される可能性があるため、商用利用の際は最新の一次情報をご確認ください。