フリーランスや個人事業主にとって、請求書作成は避けて通れない作業だ。Excel テンプレートを使う、クラウド請求書サービスに登録する、といった方法が一般的だが、どれも一長一短がある。Excel は体裁が崩れやすいし、クラウドサービスはアカウント登録が必要で、金額や取引先名といった機密性の高い情報を外部サーバーに預けることになる。
「フォームに入力したらその場で PDF が生成されて、データは一切外部に出ない」——そんなツールがあれば便利だと思い、ブラウザ完結の請求書 PDF 生成ツールを作った。技術的には pdf-lib を使って JavaScript だけで PDF を組み立てている。この記事では、その実装の中身を解説する。
pdf-lib とは何か
pdf-lib は、JavaScript / TypeScript で PDF の作成・編集ができるライブラリだ。ブラウザでも Node.js でも動き、外部のネイティブバイナリに依存しない。今回のツールでは v1.17.1 を使用している。
pdf-lib の基本的な流れはこうなる。
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'
const pdfDoc = await PDFDocument.create()
const page = pdfDoc.addPage([595.28, 841.89]) // A4サイズ(ポイント単位)
const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
page.drawText('Hello', {
x: 50,
y: 800,
size: 12,
font,
color: rgb(0, 0, 0),
})
const pdfBytes = await pdfDoc.save()
PDFDocument.create() で空の PDF を作り、addPage() でページを追加、drawText() や drawRectangle() で描画していく。HTML/CSS のようなレイアウトエンジンはないので、すべての要素を座標指定で配置する必要がある。
PDF の座標系を理解する
pdf-lib で最初につまずくのが座標系だ。Web の <canvas> や CSS では左上が原点で Y 軸は下方向が正だが、PDF は 左下が原点 で Y 軸は上方向が正になる。
PDF座標系:
(0, 841.89) ────────── (595.28, 841.89)
│ │
│ ↑ Y軸(上が正) │
│ │ │
│ └──→ X軸(右が正) │
│ │
(0, 0) ──────────── (595.28, 0)
A4 サイズは 595.28 x 841.89 ポイント(1 ポイント = 1/72 インチ)。実装では上端から描画を始め、要素を配置するたびに y 座標を減算していく。
const { width, height } = page.getSize()
let y = height - 50 // ページ上端から50pt下がった位置からスタート
page.drawText('INVOICE', { x: 50, y, size: 28, font: fontBold })
y -= 30 // 次の要素は30pt下
page.drawText('No. INV-001', { x: 50, y, size: 11, font: fontBold })
y -= 20
この「y を減算しながら下へ進む」パターンが、pdf-lib でのレイアウトの基本になる。
テーブル(明細表)の描画
請求書の核心は明細テーブルだ。HTML の <table> のようなものは pdf-lib にはないので、矩形とテキストを手動で組み合わせて表を作る。
まず、各カラムの X 座標と幅を定義する。
const marginLeft = 50
const contentWidth = width - marginLeft - 50 // 右マージン50
const colX = {
no: marginLeft,
name: marginLeft + 24,
qty: marginLeft + contentWidth * 0.58,
unit: marginLeft + contentWidth * 0.68,
subtotal: marginLeft + contentWidth * 0.82,
}
ヘッダー行は drawRectangle() で背景を塗り、その上にテキストを配置する。
// ヘッダー背景(濃いグレー)
page.drawRectangle({
x: marginLeft,
y: y - 18,
width: contentWidth,
height: 22,
color: rgb(0.2, 0.2, 0.2),
})
// ヘッダーテキスト(白文字)
page.drawText('Item', { x: colX.name, y: y - 12, size: 9, font: fontBold, color: rgb(1, 1, 1) })
page.drawText('Qty', { x: colX.qty, y: y - 12, size: 9, font: fontBold, color: rgb(1, 1, 1) })
データ行はゼブラストライプ(交互背景色)で視認性を上げている。
for (let i = 0; i < lineItems.length; i++) {
const rowBg = i % 2 === 0 ? rgb(1, 1, 1) : rgb(0.97, 0.97, 0.97)
page.drawRectangle({ x: marginLeft, y: y - 14, width: contentWidth, height: 18, color: rowBg })
// ...テキスト描画
y -= 18
}
数値の右寄せ — widthOfTextAtSize の活用
金額や数量のカラムは右寄せにしないと見づらい。pdf-lib には text-align: right のような指定はないので、テキスト幅を測定して X 座標を計算する。
const amount = '1,500,000'
const textWidth = fontBold.widthOfTextAtSize(amount, 8.5)
page.drawText(amount, {
x: colX.subtotal + colWidth - textWidth, // カラム右端から逆算(colWidthはカラム幅)
y: y - 8,
size: 8.5,
font: fontBold,
})
font.widthOfTextAtSize(text, fontSize) がテキストのレンダリング幅をポイント単位で返してくれる。カラムの右端座標からこの幅を引けば、右寄せの X 座標が求まる。請求書番号の右寄せ配置にも同じテクニックを使っている。
消費税計算のロジック
消費税は 0%・8%・10% の 3 種類から選択できる。計算ロジックはシンプルだが、端数処理に Math.floor() を使っている点がポイント。
const subtotal = lineItems.reduce((sum, item) => {
return sum + parseNumber(item.quantity) * parseNumber(item.unitPrice)
}, 0)
const taxAmount = Math.floor(subtotal * taxRate / 100)
const total = subtotal + taxAmount
Math.floor() による切り捨ては、国税庁が認める端数処理の一つ。実務では切り捨て・切り上げ・四捨五入のいずれも認められているが、切り捨てを採用するケースが多い。
数値入力にはカンマ区切りが入力される可能性があるため、parseNumber() でカンマを除去してから parseFloat() に渡している。
function parseNumber(str: string): number {
const n = parseFloat(str.replace(/,/g, ''))
return isNaN(n) ? 0 : n
}
フォント処理 — 日本語入力をどう扱うか
pdf-lib で日本語を扱うには、通常は日本語フォントファイル(.ttf / .otf)を埋め込む必要がある。しかしフォントファイルは数 MB あり、ブラウザでの初回ロードが重くなる。
このツールでは割り切った設計をしている。フォントは PDF 標準の Helvetica / HelveticaBold を使い、入力された日本語テキストは ASCII に変換して出力する。
const fontRegular = await pdfDoc.embedFont(StandardFonts.Helvetica)
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold)
全角→半角変換は toAscii() 関数で行う。全角英数字を半角に、全角スペースを半角スペースに変換する。
function toAscii(str: string): string {
return str
.replace(/[!-~]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
.replace(/ /g, ' ')
}
Unicode の全角英数字(! = U+FF01 〜 ~ = U+FF5E)は、対応する半角文字のコードポイントに 0xFEE0 を足した値になっている。だから 0xFEE0 を引くだけで半角に変換できる。
StandardFonts を使うことで、外部フォントのダウンロードが不要になり、PDF 生成が高速になるメリットがある。もし日本語の直接出力が必要なら、fontkit を使ってカスタムフォントを埋め込むアプローチになる。
ブラウザ完結のダウンロード処理
生成された PDF はサーバーを経由せず、ブラウザ内で直接ダウンロードされる。仕組みは Blob URL を使った定番パターンだ。
const pdfBytes = await pdfDoc.save()
const blob = new Blob([pdfBytes.buffer], { type: 'application/pdf' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `invoice-${invoiceNumber}.pdf`
a.click()
URL.revokeObjectURL(url)
PDFDocument.save() が Uint8Array を返すので、それを Blob に変換し、URL.createObjectURL() で一時的な URL を生成する。非表示の <a> 要素を作って click() を呼ぶとダウンロードが始まる。ダウンロード後は revokeObjectURL() でメモリを解放する。
この方法なら、ユーザーが入力した会社名・金額・取引先情報がネットワークに一切流れない。DevTools の Network タブを開いてもらえば、PDF ダウンロード時にリクエストが飛んでいないことが確認できる。
設計上の工夫
レイアウトの構造化
請求書 PDF のレイアウトは、上から順に以下のブロックで構成している。
- タイトル(
INVOICE)+ 請求書番号(右寄せ) - オレンジのアクセントライン
- 請求先(左)と発行者情報(右)の 2 カラム
- 発行日・支払期限
- 合計金額のハイライトボックス
- 明細テーブル(ヘッダー + 最大 20 行)
- 小計・消費税・合計のサマリー
- 備考欄
- フッター
合計金額のハイライトは drawRectangle() で背景色付きのボックスを描き、その中にテキストを配置している。色は rgb(0.9, 0.36, 0.12) のオレンジ系で統一し、視認性と統一感を両立させた。
入力値のバリデーション
テキストの長さは truncate() 関数で制限している。例えば会社名は 40 文字、品目名はカラム幅から逆算した文字数で切り詰める。PDF は固定幅レイアウトなので、長すぎるテキストが隣のカラムに食い込むのを防ぐ必要がある。
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str
return str.slice(0, maxLen - 1) + '.'
}
まとめ
pdf-lib を使えば、外部バイナリやサーバーサイド処理なしに、ブラウザだけで PDF を生成できる。座標系の理解、widthOfTextAtSize() による右寄せ、drawRectangle() と drawText() の組み合わせによるテーブル描画など、HTML/CSS とは異なるアプローチが必要だが、仕組みを理解すれば請求書レベルのレイアウトは十分に実現できる。
特にプライバシーの観点では、入力データがサーバーに送信されないブラウザ完結の設計は大きなメリットになる。金額や取引先名のような機密情報を扱う請求書だからこそ、この設計が活きてくる。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理などの開発者向けツールを公開中。すべて無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。