ReoGrid ReoGrid Web

ブラウザだけで請求書PDFを生成する ― テンプレート1枚に、データを流し込む

· unvell team
ブラウザだけで請求書PDFを生成する ― テンプレート1枚に、データを流し込む

「請求書をPDFでお客様にメール送信する」― 週末で終わりそうに見えて、気づけばインフラの話になっている機能です。サーバーのどこかで動くヘッドレス Chrome、誰も思いどおりに改ページさせられない印刷用スタイルシート、ローカルでは出るのに本番では豆腐(□□□)になるフォント。その一方で、必要なレイアウトはとっくに存在しています ― 何年も前に経理の誰かが Excel で作った、あの様式です。しかもそれは正しい。税額の行は、税額の行があるべき場所にあります。

ReoGrid Web はそこを出発点にします。ワークシート上に様式を一度描き、どの行が繰り返しなのかを宣言し、プレーンな JavaScript オブジェクトを渡す。出てくるのは本物のベクター PDF ― しかもすべてクライアントサイド、ユーザーが今見ているそのページの中で完結します。

レポートバインディングと PDF 出力は Pro 機能です(料金)。読み取り側 ― getReportTemplate / getReportState ― は Lite でも動き、バインド結果はただのセルなので Lite のビューアーでもそのまま表示されます。


パイプライン全体は3つの呼び出し

import { createReogrid, preloadPdfFont } from '@reogrid/pro';

const grid = createReogrid({ workspace: '#grid', licenseKey: 'YOUR-LICENSE-KEY' });
const ws = grid.worksheet;

// 1. シートに描いた様式をテンプレートとして取り込む
ws.defineReportTemplate({
  columns: [0, 4],
  sections: [
    { role: 'header', rows: [0, 5] },
    { role: 'detail', rows: [6, 6], source: 'items' },
    { role: 'footer', rows: [7, 9] },
  ],
});

// 2. データを流し込む ― 明細バンドがレコード件数ぶん繰り返される
ws.bindReport({ invoiceNo: 'INV-2026-0042', items: [/* … */] });

// 3. ベクターPDFとして書き出す
await preloadPdfFont('ja');                       // アプリ起動時に一度だけ
grid.saveAsPdf({ locale: 'ja', usePageBreaks: true, filename: 'INV-2026-0042.pdf' });

2 と 3 のあいだにあるものが、この仕組みの肝です。展開された帳票はただのセルです ― キャンバス上のオーバーレイでもなければ、印刷専用の DOM ツリーでもありません。だからグリッドにできることは、そのまま展開後の帳票にも効きます。数式は再計算され、xlsx 出力は同じ内容を .xlsx として書き出し、ユーザーはスクロールでき、改ページプレビューは紙に載る姿をそのまま見せます。


ステップ1 ― 様式を一度だけ描く

固定の文字はそのままグリッドに打ち込み、値が入る場所に {{token}} を置きます。事前登録は何もいりません。トークンはただのセルの中身です。

// ヘッダー
ws.cell('A1').setValue('請求書');
ws.cell('A3').setValue('請求番号:'); ws.cell('B3').setValue('{{invoiceNo}}');
ws.cell('A4').setValue('発行日:');   ws.cell('B4').setValue('{{date}}');
ws.cell('A5').setValue('宛先:');     ws.cell('B5').setValue('{{customer.name}}');

// 明細 ― 設計行1行が、レコードごとに繰り返される
ws.cell('A7').setValue('{{#index}}');
ws.cell('B7').setValue('{{name}}');
ws.cell('C7').setValue('{{qty}}');
ws.cell('D7').setValue('{{price}}');
ws.cell('E7').setValue('=C7*D7');     // 行ごとの数式 ― 相対参照がレコードごとにずれる

// フッター
ws.cell('D9').setValue('合計');
ws.cell('E9').setValue('=SUM(E7:E7)'); // 集計は展開後の明細範囲まで広がる

出力のふるまいを決めるのは、次の4点です。

  • トークンだけのセルは、バインドされた値の型を保ちます。 {{qty}}12 をバインドすれば数値の 12 として入り、文字列の "12" にはなりません ― だから数値書式も右揃えも、そのセルを参照する数式も、そのまま機能します。
  • ドット記法でオブジェクトを掘れます。 {{customer.name}}data.customer.name を読みます。
  • 明細バンドではレコードごとに2つのトークンが自動で使えます。 {{#index}}(1始まりの行番号)と {{#count}}(総件数)― 印刷された請求書の「No」列を、データ側に持たせずに済ませるためのものです。
  • 数式は設計位置に対して書き、展開時にずれます。 設計行6の =C7*D7 は、繰り返しに応じて =C7*D7=C8*D8=C9*D9… となり、フッターの =SUM(E7:E7) は生成された明細範囲全体をカバーするように広がります。

ステップ2 ― どの行がどのバンドかを宣言する

テンプレートは、0始まり・両端を含む設計行の連続したバンドのリストです。

ws.defineReportTemplate({
  columns: [0, 4],                                          // A:E ― 展開対象の列範囲
  sections: [
    { role: 'header', rows: [0, 5] },
    { role: 'detail', rows: [6, 6], source: 'items', pageBreakEvery: 20 },
    { role: 'footer', rows: [7, 9] },
  ],
});
フィールド対象役割
role'header' | 'detail' | 'footer'全部どのバンドか
rows[top, bottom]全部0始まり・両端を含む設計行の範囲
sourcestringdetailバインドデータ内のキー(ドット記法可)― 繰り返しを駆動する配列
keepTogetherboolean(既定 truedetail1レコードをページ境界で分断しない
pageBreakEverynumberdetailNレコードごとに手動改ページを挿入 ― 固定行数の帳票向け

セクションは連続している必要があり、これには実用上の帰結がひとつあります。空白のスペーサー行は、2つのバンドの「あいだ」ではなく、どちらかのバンドの「中」の行だということです。宛先の下に余白を取りたいなら、その行はヘッダーバンドに含めます。

明細バンドは1行である必要もありません。rows: [6, 7] とすれば、1レコードが2行のブロックとして展開されます ― 上段に品名、下段に備考、という日本の帳票でよくある明細の形です。


ステップ3 ― データをバインドする

ReportData はプレーンなオブジェクトです。トップレベルのフィールドがヘッダー/フッターのトークンを、各明細の source をキーとする配列が繰り返しを受け持ちます。

ws.bindReport({
  invoiceNo: 'INV-2026-0042',
  date: '2026-08-31',
  customer: { name: '株式会社サンプル' },
  items: [
    { name: 'Webサイト制作',       qty: 1,  price: 350000 },
    { name: '保守サポート(月額)',   qty: 12, price: 30000 },
    { name: 'ドメイン取得',        qty: 2,  price: 5000 },
  ],
});

バンドは縦方向に展開されます ― 行は下へずれ、列は決して動きません。列位置が意味を持つ様式(帳票なら、まさにそうです)が横にずれる心配はありません。

別のデータで再度バインドすると、取り込み済みのテンプレートから作り直されます。先に unbind する必要はありません。記事の最後のバッチ処理は、この性質に乗っています。

ws.unbindReport();     // トークン入りのテンプレート表示に戻す
ws.getReportState();   // 'none' | 'template' | 'bound'
ws.getReportTemplate();

テンプレートはシートごとに ReoGrid JSON へ直列化されるので、設計した様式を保存し、別のマシンに渡し、そこで読み込んで新しいデータをバインドできます。設計ツールと出力アプリが同じアプリである必要はありません。


紙のようにページを割る

PDF はページの束なので、面白い判断はすべて「どこで切るか」に集まります。それは書き出しのオプションではなく、シート側の印刷設定で決めます。

ws.setPrintSettings({
  paperSize: 'A4',
  orientation: 'portrait',
  margins: { top: 12, right: 12, bottom: 12, left: 12 },
  headerFooter: {
    header: { center: '請求書', right: '&D' },
    footer: { center: 'ページ &P / &N' },
  },
});
ws.setShowPageBreaks(true);   // 画面に改ページプレビューを表示

ヘッダー・フッターの文字列は Excel と同じ書式コードで、描画時にページごとに展開されます ― &P ページ番号、&N 総ページ数、&D 日付、&T 時刻、&A シート名、&F ファイル名。上下それぞれに left / center / right の3区画 ― Excel のページ設定ダイアログと同じ構成です。(ページヘッダー・フッターは v1.5、レポートバインディングと PDF 出力は v1.4 からの機能です。)

改ページの制御は、答える質問が違う2つのレバーで行います。

  • 明細バンドの pageBreakEvery: 20「この様式は1ページ20行きっかり」。固定フォーム帳票の定番要件で、内容の高さより優先されます。
  • keepTogether: true(既定) ― 「何があっても1レコードは分断しない」。1レコードが1ページより高くなるような特殊なケース以外は、オンのままで構いません。

setShowPageBreaks(true) は見た目以上に重要です。書き出し時に usePageBreaks: true を渡せば、画面に見えているものと PDF から出てくるものが同じページングモデルから計算されます ― 「ブラウザでは大丈夫だったのに」がなくなります。


PDFを書き出す

import { preloadPdfFont } from '@reogrid/pro';

await preloadPdfFont('ja');    // 自分で決めた非同期の地点で、一度だけ

grid.saveAsPdf({ locale: 'ja', usePageBreaks: true, filename: 'INV-2026-0042.pdf' });

この呼び出しには驚かれる点が2つあり、どちらも意図的な設計です。

フォントは必須です ― 英数字だけの請求書でも。 レンダラーはグリフのアウトラインを PDF 自身に埋め込みます。だからこそ、フォントが1つも入っていないマシンでも同じ見た目で開けます。locale'ja''zh-CN''zh-TW''ko' は最初から登録済み)を指定するか、自前の TrueType バイト列を font に渡してください。どちらも無ければ書き出しは例外になります。Noto Sans JP はラテン文字もカバーするので、英語の請求書でも 'ja' は十分に妥当な既定値です ― ただしラテン文字だけの帳票なら、自前の小さいフォントを登録したほうがダウンロードは軽くなります。

書き出し自体は同期的です。 saveAsPdf は最後に生成したリンクをクリックして終わりますが、その手前に await があるとユーザージェスチャーの文脈が切れ、ポップアップブロッカーにダウンロードを食われます。だからフォントの取得はアプリ起動時に一度だけ払う明示的なステップに分けてあり、以降の書き出しは即座に走ります。本番では、バイト列を npm から持ってくるサブセットパッケージを使えば CDN 自体が不要になります。

// npm install @reogrid/font-jp   (他に font-sc → 'zh-CN'、font-tc → 'zh-TW'、font-kr → 'ko')
import { registerPdfFont, preloadPdfFont } from '@reogrid/pro';
import { loadNotoSansJP } from '@reogrid/font-jp';

registerPdfFont('ja', loadNotoSansJP);
await preloadPdfFont('ja');

ダウンロードではなくバイト列が欲しい場合 ― サーバーへ送る、保管する、画面内でプレビューする ― は exportPdf を使います。

const bytes: Uint8Array = grid.exportPdf({ locale: 'ja', usePageBreaks: true });

await fetch('/api/invoices/INV-2026-0042.pdf', {
  method: 'POST',
  headers: { 'Content-Type': 'application/pdf' },
  body: bytes,
});

各ファイルには実際に使われたグリフだけが埋め込まれます(書き出し時にサブセット化されます)ので、請求書を何通作っても1通ごとに数MBのフォントを背負うことはありません。


実例:請求書ひとそろい

ここまでの全部を1ファイルにしたものです。A〜E列、ヘッダーが設計行0〜5、繰り返す明細が1行、フッターが7〜9、そしてボタン2つ。

import { createReogrid, preloadPdfFont } from '@reogrid/pro';
import type { ReportData } from '@reogrid/pro';

const grid = createReogrid({ workspace: '#grid', licenseKey: 'YOUR-LICENSE-KEY' });
const ws = grid.worksheet;

const NUM = '#,##0';
ws.suspendRender();
[56, 240, 70, 110, 130].forEach((w, i) => ws.column(i).setWidth(w));

// ── ヘッダーバンド:設計行 0〜5 ────────────────────────────────────────────
ws.setCellInput(0, 0, '請求書');
ws.mergeCells(0, 0, 0, 4);
ws.setCellStyle(0, 0, { bold: true, fontSize: 20, textAlign: 'center', verticalAlign: 'middle' });
ws.row(0).setHeight(44);
ws.row(1).setHeight(12);                                  // スペーサー ― バンドの「中」の行
ws.setCellInput(2, 0, '請求番号:'); ws.setCellInput(2, 1, '{{invoiceNo}}');
ws.setCellInput(2, 3, '発行日:');   ws.setCellInput(2, 4, '{{date}}');
ws.setCellInput(3, 0, '宛先:');     ws.setCellInput(3, 1, '{{customer.name}} 御中');
ws.mergeCells(3, 1, 3, 4);
ws.setCellStyle(3, 1, { bold: true, verticalAlign: 'middle' });
ws.row(4).setHeight(12);
['No', '品名', '数量', '単価', '金額'].forEach((h, c) => {
  ws.setCellInput(5, c, h);
  ws.setCellStyle(5, c, {
    bold: true, backgroundColor: '#1e3a5f', color: '#ffffff',
    textAlign: 'center', verticalAlign: 'middle',
  });
});

// ── 明細バンド:設計行6(= A1 の7行目)、品目ごとに繰り返し ────────────────
ws.setCellInput(6, 0, '{{#index}}');
ws.setCellInput(6, 1, '{{name}}');
ws.setCellInput(6, 2, '{{qty}}');
ws.setCellInput(6, 3, '{{price}}');
ws.setCellInput(6, 4, '=C7*D7');
[2, 3, 4].forEach(c => ws.setCellNumberFormat(6, c, NUM));
ws.setCellStyle(6, 0, { textAlign: 'center' });
[3, 4].forEach(c => ws.setCellStyle(6, c, { textAlign: 'right' }));

// ── フッターバンド:設計行 7〜9 ───────────────────────────────────────────
ws.row(7).setHeight(12);
ws.setCellInput(8, 3, '小計');       ws.setCellInput(8, 4, '=SUM(E7:E7)');
ws.setCellInput(9, 3, '合計(税込)'); ws.setCellInput(9, 4, '=E9*1.1');
[8, 9].forEach(r => {
  ws.setCellNumberFormat(r, 4, NUM);
  ws.setCellStyle(r, 3, { textAlign: 'right' });
  ws.setCellStyle(r, 4, { textAlign: 'right' });
});
ws.resumeRender();

// ── テンプレートと紙面 ────────────────────────────────────────────────────
ws.defineReportTemplate({
  columns: [0, 4],
  sections: [
    { role: 'header', rows: [0, 5] },
    { role: 'detail', rows: [6, 6], source: 'items', pageBreakEvery: 20 },
    { role: 'footer', rows: [7, 9] },
  ],
});

ws.setPrintSettings({
  paperSize: 'A4',
  orientation: 'portrait',
  headerFooter: { footer: { center: 'ページ &P / &N', right: '&D' } },
});
ws.setShowPageBreaks(true);

void preloadPdfFont('ja');   // ユーザーが様式を眺めているあいだにフォントを温めておく

// ── データを入れて、PDFを出す ─────────────────────────────────────────────
const invoice: ReportData = {
  invoiceNo: 'INV-2026-0042',
  date: '2026-08-31',
  customer: { name: '株式会社サンプル' },
  items: [
    { name: 'Webサイト制作',     qty: 1,  price: 350000 },
    { name: '保守サポート(月額)', qty: 12, price: 30000 },
    { name: 'ドメイン取得',      qty: 2,  price: 5000 },
  ],
};

document.getElementById('render')!.onclick = () => ws.bindReport(invoice);
document.getElementById('pdf')!.onclick = () =>
  grid.saveAsPdf({ locale: 'ja', usePageBreaks: true, filename: `${invoice.invoiceNo}.pdf` });

render を押せば3件の明細が行番号と行ごとの金額つきで並び、小計は展開後の範囲まで広がります。pdf を押せば同じものが、画面のプレビューが示したとおりのページ割りでダウンロードフォルダに落ちます。レポートバインディングのデモはこの形をライブで動かしていて(5件バインド・20件バインド・解除・xlsx保存)、PDF・ページレイアウトのデモがページングとヘッダー・フッター側を見せます。


テンプレート1枚から、請求書100通

バインドがテンプレートから作り直す仕組みなので、月次の請求処理はただのループです。サーバーもキューも要りません。

await preloadPdfFont('ja');   // 一度だけ ― 以降はキャッシュから

const files: { name: string; bytes: Uint8Array }[] = [];

for (const invoice of invoices) {
  ws.bindReport(invoice);                                        // 同じ様式に詰め直す
  files.push({
    name: `${invoice.invoiceNo}.pdf`,
    bytes: grid.exportPdf({ locale: 'ja', usePageBreaks: true }),
  });
  await new Promise(resolve => setTimeout(resolve));             // UIに息継ぎをさせる
}

bindReportexportPdf も同期処理なので、レコードのあいだに挟んだ setTimeout(0) が長いバッチでタブを固まらせないための要になります ― 数通なら外してよく、100通ならループのインデックスから進捗バーを回してください。files をどうするかは自由です。サーバーへ POST する、クライアント側で zip にまとめる、そのままユーザーに渡す。


Lite と Pro の境界線

操作Lite(無料)Pro
バインド済み帳票の表示 ― 中身はただのセル
テンプレートと状態の読み取り(getReportTemplategetReportState
defineReportTemplate / bindReport / unbindReport
exportPdf / saveAsPdf

ピボットテーブルデータ検証と同じ線引きです ― ライセンスを背負うのは作成側で、表示側ではありません。Pro ライセンスのバックオフィスで様式を設計して帳票を発行し、受け取り側の Lite ベースのビューアーでそれを表示する、という構成が組めます。


まとめ

業務で実際に使われているレイアウトは、もう描かれています ― 仕事はそこにデータを流し込み、反対側から紙を出すことです。ここではそれが3つの呼び出しになります。defineReportTemplate が様式を取り込み、bindReport が明細をレコードごとに繰り返して数式をずらし集計を広げ、saveAsPdf がプレビューどおりのページ割りでベクター PDF を書き出す。Excel 書式コードのページヘッダー・フッター、1ページあたりの固定行数、ロケール指定の CJK フォント、そして1通では足りないときのバッチループ ― サーバーを経由せず、ファイルをブラウザの外に出さずに。

まずはレポートバインディングのデモPDF・ページレイアウトのデモから。API 全体はレポートバインディングPDF出力のドキュメントへ。短い版が欲しければ帳票テンプレートに差し込むPDFを書き出す。そして、バインドしたい様式が今まさに誰かのデスクトップの .xlsx にあるなら、先に読むべきはExcelの帳票レイアウトをWebへ移す話です。

ReoGrid Web を試してみる

React/Vue 向けの Canvas ベース Excel 互換スプレッドシートコンポーネント。 Lite は無料 — npm install 一発で始められます。

関連記事

ReoGrid Web v1.5 を発表 — 検索と置換、ドラッグ移動、ページヘッダー/フッター、そして中国語が化けない PDF

ReoGrid Web v1.5 は「毎日の操作」を埋めるリリースです。Ctrl/Cmd+F の Excel スタイル検索と置換、範囲・行・列のドラッグ移動、Excel の書式コードで書けるページヘッダー/フッター、そしてロケールを指定するだけで適切な CJK フォントを埋め込む PDF エクスポート。公開 API に破壊的変更はありません。

ReoGrid Web v1.4 を発表 — ページレイアウト、印刷そのままの PDF エクスポート、そして Excel グレードのデータ機能

ReoGrid Web v1.4 はグリッドを「帳票ツール」に進化させます — 改ページプレビュー付きの Excel スタイルのページレイアウトと、プレビューと同一のページ割りで出力する自社オリジナルエンジンの PDF エクスポート。さらにピボットテーブル、テーブルスタイル、データの入力規則、レポートバインディング、セルコメント、名前付き範囲。公開 API に破壊的変更はありません。

JavaScriptスプレッドシートの VLOOKUP・XLOOKUP・SUMIFS ― 単価も集計も自動で追随する受注明細表

商品コードから単価を引き、区分ごとに集計する受注明細表をブラウザで組み立てる。VLOOKUP と XLOOKUP の違い、マスタに無いコードの扱い(if_not_found・IFNA・IFERROR)、SUMIF / SUMIFS / COUNTIFS の criteria 記法と引数順の落とし穴、そして ReoGrid Web に「無い」2つの参照関数まで。

ニュースレター

開発の最新情報をお届けします

新しいリリース・機能追加・お知らせをいち早く受け取るには、
メーリングリストにご登録ください。