記事一覧

Go で帳票 PDF を作る — 仕訳帳・総勘定元帳・残高試算表

仕訳データ 1 本から仕訳帳・総勘定元帳・残高試算表を Go で PDF 出力する。罫線表・金額右寄せ・自動改ページまで動く gpdf の完全なコード付き。

2026年11月14日 公開
著者: Taiki Noda

TL;DR

仕訳の配列が 1 本あれば、仕訳帳・総勘定元帳・合計残高試算表 の 3 帳票は同じ Go プログラムから出せる。gpdf v1.0.17 で、main.go は 239 行。罫線入りの表、金額の右寄せ、ページをまたぐときの見出し行の繰り返し、1 / 3 形式のページ番号まで入っている。Chromium も CGO も帳票ツールのライセンスも要らない。

67 仕訳 (1 か月分) の仕訳帳は 2 ページ、生成は Apple M1 で約 9 ms。10,000 仕訳にすると 170 ページになり、約 0.7 秒で出る。

帳票は「表」ではなく「集計の見せ方」

Go で帳票を出したい、という相談はだいたい「罫線の引き方」から始まる。でも詰まるのはそこではない。

仕訳帳・総勘定元帳・試算表は、別々の帳票に見えて元データは同じ。仕訳帳は仕訳を日付順に並べたもの。総勘定元帳は同じ仕訳を勘定科目ごとに並べ替えて残高を足し込んだもの。試算表はそれをさらに科目 1 行に畳んだもの。つまり 3 帳票の違いは GROUP BY の違いでしかない。

だから設計はこうなる。データは 1 種類、集計関数が 3 つ、描画関数が 1 つ。描画側が gpdf の c.Table で、ここは本当に薄い。この記事の分量も、その比率どおり集計側に寄せてある。

対象読者は、会計・販売管理・経費精算あたりの業務システムを Go で書いていて、「帳票出力」の一行が要件定義書に入ってしまった人。

データモデル: 仕訳は 1 種類だけ

type Entry struct {
	Date, Debit, Credit, Memo string
	Amount                    int64
}

1 仕訳は「借方科目・貸方科目・金額」の組。金額は int64 の円単位で持つ。float64 で持つと、試算表の貸借が 1 円ずれたときに原因を探して夜が明ける。これは誇張ではなく、0.1 + 0.2 が 0.3 にならない世界で貸借一致を検証するのは単純に無理がある。

勘定科目は 2 つのテーブルで表す。

var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}

var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}

accounts は表示順。元帳と試算表の並びはこれで決まる。debitSide は「増えたら借方に書く科目」、つまり資産と費用。負債・純資産・収益はその逆で、貸方に書くと残高が増える。元帳の残高欄を計算するときに、この向きが要る。

ここは簡略化している。実務の仕訳は 1 対 1 とは限らない。給与の支払いなら借方 1 行に対して貸方が「普通預金・預り金 (源泉所得税)・預り金 (社会保険料)」と 3 行並ぶ。こういう複合仕訳を扱うなら、Entry を「伝票番号 + 明細行の配列」に変える。集計関数は明細行を回すようになるだけで、描画側は 1 行も変わらない。

完全なコード

go mod init したディレクトリに NotoSansJP-Regular.ttf を置いて、これを main.go として保存する。gpdf v1.0.17 で動作確認済み。

go get github.com/gpdf-dev/gpdf
package main

import (
	"fmt"
	"log"
	"os"
	"sort"
	"strconv"

	"github.com/gpdf-dev/gpdf/document"
	"github.com/gpdf-dev/gpdf/pdf"
	"github.com/gpdf-dev/gpdf/template"
)

// Entry は 1 本の仕訳。金額は円の整数で持つ。float64 は使わない。
type Entry struct {
	Date, Debit, Credit, Memo string
	Amount                    int64
}

// 勘定科目コード順。試算表と元帳の並びはこれで決まる。
var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}

// 借方残高になる科目 (資産・費用)。それ以外は貸方残高。
var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}

func yen(n int64) string {
	if n == 0 {
		return ""
	}
	s := strconv.FormatInt(n, 10)
	for i := len(s) - 3; i > 0; i -= 3 {
		s = s[:i] + "," + s[i:]
	}
	return s
}

func newDoc(font []byte, title string) *template.Document {
	doc := template.New(
		template.WithPageSize(document.A4),
		template.WithMargins(document.UniformEdges(document.Mm(15))),
		template.WithFont("NotoSansJP", font),
		template.WithDefaultFont("NotoSansJP", 9),
	)
	doc.Header(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(6, func(c *template.ColBuilder) {
				c.Text(title, template.FontSize(16))
			})
			r.Col(6, func(c *template.ColBuilder) {
				c.Text("株式会社サンプル商事", template.AlignRight())
				c.Text("自 2026年4月1日 至 2026年4月30日", template.AlignRight())
			})
		})
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) { c.Spacer(document.Mm(4)) })
		})
	})
	doc.Footer(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder,
					template.AlignCenter(), template.FontSize(8), template.TextColor(pdf.Gray(0.4)))
			})
		})
	})
	return doc
}

func table(c *template.ColBuilder, header []string, rows [][]string, widths []float64, aligns []document.TextAlign) {
	// v1.0.17 の c.Table にはセル内余白のオプションがない。罫線に文字が
	// 貼り付くので、揃える側に半角スペースを 1 つ足して逃がす。
	pad := func(row []string) []string {
		out := make([]string, len(row))
		for i, s := range row {
			switch {
			case s == "":
			case aligns[i] == document.AlignRight:
				out[i] = s + " "
			case aligns[i] == document.AlignLeft:
				out[i] = " " + s
			default:
				out[i] = s
			}
		}
		return out
	}
	header = pad(header)
	padded := make([][]string, len(rows))
	for i, row := range rows {
		padded[i] = pad(row)
	}
	grid := template.Border(template.BorderWidth(document.Pt(0.5)), template.BorderColor(pdf.Gray(0.3)))
	c.Table(header, padded,
		template.ColumnWidths(widths...),
		template.ColumnAlign(aligns...),
		template.TableHeaderStyle(template.BgColor(pdf.RGBHex(0xEEEEEE))),
		template.WithTableCellBorder(grid),
		template.WithTableBorderCollapse(true),
	)
}

func save(doc *template.Document, name string) {
	b, err := doc.Generate()
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile(name, b, 0o644); err != nil {
		log.Fatal(err)
	}
}

const L, R = document.AlignLeft, document.AlignRight

// 仕訳帳: 日付順に 1 仕訳 1 行。
func journal(font []byte, entries []Entry) {
	var total int64
	rows := make([][]string, 0, len(entries)+1)
	for _, e := range entries {
		rows = append(rows, []string{e.Date, e.Debit, yen(e.Amount), e.Credit, yen(e.Amount), e.Memo})
		total += e.Amount
	}
	rows = append(rows, []string{"", "合計", yen(total), "", yen(total), ""})

	doc := newDoc(font, "仕訳帳")
	doc.AddPage().AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
				[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})
		})
	})
	save(doc, "journal.pdf")
}

// 総勘定元帳: 科目ごとに仕訳を転記し、残高を累計する。
func ledger(font []byte, entries []Entry) {
	doc := newDoc(font, "総勘定元帳")
	page := doc.AddPage()
	for _, acct := range accounts {
		var bal int64
		var rows [][]string
		for _, e := range entries {
			var dr, cr int64
			var counter string
			switch acct {
			case e.Debit:
				dr, counter = e.Amount, e.Credit
			case e.Credit:
				cr, counter = e.Amount, e.Debit
			default:
				continue
			}
			if debitSide[acct] {
				bal += dr - cr
			} else {
				bal += cr - dr
			}
			rows = append(rows, []string{e.Date, counter, e.Memo, yen(dr), yen(cr), yen(bal)})
		}
		if len(rows) == 0 {
			continue
		}
		page.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(acct, template.FontSize(11))
				c.Spacer(document.Mm(1))
				table(c, []string{"日付", "相手科目", "摘要", "借方", "貸方", "残高"}, rows,
					[]float64{10, 14, 34, 14, 14, 14}, []document.TextAlign{L, L, L, R, R, R})
				c.Spacer(document.Mm(5))
			})
		})
	}
	save(doc, "ledger.pdf")
}

// 合計残高試算表: 科目ごとの借方合計・貸方合計と、その差額。
func trialBalance(font []byte, entries []Entry) {
	dr, cr := map[string]int64{}, map[string]int64{}
	for _, e := range entries {
		dr[e.Debit] += e.Amount
		cr[e.Credit] += e.Amount
	}
	var sumDr, sumCr, balDr, balCr int64
	var rows [][]string
	for _, a := range accounts {
		var bd, bc int64
		if d := dr[a] - cr[a]; d > 0 {
			bd = d
		} else {
			bc = -d
		}
		rows = append(rows, []string{yen(bd), yen(dr[a]), a, yen(cr[a]), yen(bc)})
		sumDr, sumCr, balDr, balCr = sumDr+dr[a], sumCr+cr[a], balDr+bd, balCr+bc
	}
	if sumDr != sumCr || balDr != balCr {
		log.Fatalf("貸借不一致: 合計 %d/%d 残高 %d/%d", sumDr, sumCr, balDr, balCr)
	}
	rows = append(rows, []string{yen(balDr), yen(sumDr), "合計", yen(sumCr), yen(balCr)})

	doc := newDoc(font, "合計残高試算表")
	doc.AddPage().AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			table(c, []string{"借方残高", "借方合計", "勘定科目", "貸方合計", "貸方残高"}, rows,
				[]float64{19, 19, 24, 19, 19}, []document.TextAlign{R, R, document.AlignCenter, R, R})
		})
	})
	save(doc, "trial_balance.pdf")
}

func main() {
	font, err := os.ReadFile("NotoSansJP-Regular.ttf")
	if err != nil {
		log.Fatal(err)
	}
	entries := []Entry{
		{"04/01", "普通預金", "資本金", "設立出資", 3000000},
		{"04/01", "現金", "普通預金", "小口現金引出", 100000},
	}
	// 実務ではここが DB クエリになる。デモ用に 1 か月分を機械的に作る。
	for d := 2; d <= 30; d++ {
		day := fmt.Sprintf("04/%02d", d)
		entries = append(entries,
			Entry{day, "仕入高", "買掛金", fmt.Sprintf("商品仕入 伝票 P-%03d", d), int64(40000 + d*1300)},
			Entry{day, "売掛金", "売上高", fmt.Sprintf("商品売上 伝票 S-%03d", d), int64(88000 + d*2100)},
		)
		if d%10 == 0 {
			entries = append(entries,
				Entry{day, "普通預金", "売掛金", "売掛金回収", 600000},
				Entry{day, "買掛金", "普通預金", "買掛金支払", 300000},
			)
		}
	}
	entries = append(entries, Entry{"04/30", "支払家賃", "普通預金", "5月分家賃", 180000})
	sort.SliceStable(entries, func(i, j int) bool { return entries[i].Date < entries[j].Date })

	journal(font, entries)
	ledger(font, entries)
	trialBalance(font, entries)
}

go run . で journal.pdf / ledger.pdf / trial_balance.pdf の 3 ファイルが出る。総勘定元帳の 1 ページ目はこうなる。

gpdf で出力した総勘定元帳の 1 ページ目。現金・普通預金・売掛金の勘定ごとに、日付・相手科目・摘要・借方・貸方・残高の罫線表が並ぶ

仕訳帳: 列幅と右寄せだけ決める

仕訳帳は 3 つの中で一番単純。仕訳 1 本を 1 行にして、最後に合計行を足す。

table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
	[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})

指定しているのは 2 つ。template.ColumnWidths に渡す列幅 (パーセント、合計 100) と、template.ColumnAlign に渡す列ごとの揃え。金額列は右寄せ、それ以外は左寄せ。ColumnAlign は見出し行にも同じ揃えを適用するので、「借方金額」という見出しも数字の真上に右寄せで乗る。

桁区切りは yen 関数で文字列にしてから渡す。gpdf は数値フォーマットを持たない。c.Table が受け取るのは [][]string で、セルの中身をどう整形するかは呼ぶ側の責任になる。ゼロを空文字にしているのは、元帳で「借方に金額がある行の貸方欄」を空欄にしたいから。0 と印字された帳票は読みにくい。

合計行の借方と貸方に同じ total を入れているのは手抜きに見えるが、1 対 1 の仕訳なら定義上そうなる。複合仕訳に拡張したら、ここは借方合計と貸方合計を別々に足す。

総勘定元帳: 転記と残高の累計

元帳は、仕訳帳を科目ごとに「転記」したもの。ある仕訳の借方が「売掛金」で貸方が「売上高」なら、その仕訳は売掛金の元帳の借方欄と、売上高の元帳の貸方欄の両方に 1 行ずつ現れる。67 仕訳が元帳では 134 行になるのはこのため。

コードでは科目ごとに全仕訳を走査している。

switch acct {
case e.Debit:
	dr, counter = e.Amount, e.Credit
case e.Credit:
	cr, counter = e.Amount, e.Debit
default:
	continue
}

その科目が借方に出てくれば借方欄に金額を入れ、相手科目として貸方科目を記録する。貸方に出てくればその逆。どちらでもなければ飛ばす。

科目数 × 仕訳数のループなので、8 科目 × 67 仕訳なら何の問題もない。300 科目 × 10 万仕訳になったら、先に map[string][]Entry へ振り分けてから回す。ただその規模なら集計は SQL でやっているはずで、Go 側には科目ごとに並んだ行が届く。

残高欄は debitSide を見て足す向きを決める。

if debitSide[acct] {
	bal += dr - cr
} else {
	bal += cr - dr
}

普通預金は借方残高の科目なので、入金 (借方) で増え、出金 (貸方) で減る。買掛金は貸方残高の科目なので逆。この分岐を忘れると、買掛金の残高がマイナスで積み上がっていく元帳ができあがる。数字は合っているのに経理担当から突き返される、いちばん説明しづらい種類のバグになる。

ちなみに、借方が左で貸方が右という並びに論理的な理由はない。複式簿記が 15 世紀のイタリアで形になったときの慣習がそのまま残っているだけで、覚えるしかない。「借方」「貸方」という訳語も、元の意味 (相手が自分に借りている / 貸している) から見ると直感と逆で、簿記の初学者が最初につまずく場所として有名だ。コードでは dr / cr と書いておくと、少なくとも変数名で混乱することは減る。

レイアウト側は、科目 1 つにつき page.AutoRow を 1 つ足しているだけ。

page.AutoRow(func(r *template.RowBuilder) {
	r.Col(12, func(c *template.ColBuilder) {
		c.Text(acct, template.FontSize(11))
		c.Spacer(document.Mm(1))
		table(c, /* ... */)
		c.Spacer(document.Mm(5))
	})
})

科目名、表、余白。これが 8 回積まれる。ページのどこで切れるかは一切計算していない。

合計残高試算表: 出力する前に落とす

試算表は科目ごとに借方合計・貸方合計を出し、その差額を残高として左右どちらかに書く。中央に勘定科目、左に借方、右に貸方という T 字の配置が慣例なので、列の順番は「借方残高・借方合計・勘定科目・貸方合計・貸方残高」になる。

この帳票でいちばん大事な行は、表を描く前にある。

if sumDr != sumCr || balDr != balCr {
	log.Fatalf("貸借不一致: 合計 %d/%d 残高 %d/%d", sumDr, sumCr, balDr, balCr)
}

試算表は本来、転記ミスを見つけるための検算表だ。借方合計と貸方合計が一致しなければ、どこかで仕訳が壊れている。だったら不一致の PDF をきれいに組版して出す意味はない。落とす。

今回のサンプルデータでは合計が 11,269,600 円、残高が 7,389,600 円で、どちらも貸借一致する。1 対 1 の仕訳しか入れていないので一致して当然だが、データが DB から来るようになった瞬間に、この 3 行が効きはじめる。本番では log.Fatalf ではなくエラーを返して、不一致の科目を調べられるようにしておく。

改ページ: 何もしていない

上のコードに改ページの処理は 1 行もない。行数を数えていないし、ページの残り高さも見ていない。

gpdf のレイアウトエンジンは、表が残りの高さに収まらないと分かった時点でボディを分割し、次のページの先頭に見出し行を描き直す。仕訳帳の 2 ページ目も、元帳で売掛金の表がページをまたいだ先も、「日付・相手科目・摘要…」の見出しから始まる。doc.Header に置いた帳票名と会計期間、doc.Footer のページ番号も全ページに出る。仕組みは 複数ページにまたがるテーブル に書いた。

ページ番号は、プレースホルダを普通の文字列に埋めているだけ。

c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder, ...)

総ページ数はページ分割が終わってから置換される。詳しくは フッターに「Page X of Y」を出す。

実測値

仕訳帳だけを、売上仕訳を機械的に並べたデータで件数を変えて計った。Apple M1、Go 1.25.2、gpdf v1.0.17。時間はフォントのバイト列を読み込んだ状態から、PDF をファイルに書き終わるまで。

仕訳数ページ数生成時間ファイルサイズ
6828.8 ms146 KB
1,0001768 ms1.9 MB
10,000170約 0.7 秒19 MB

68 件で 8.8 ms というのは、gpdf の「1 ページ 13 µs」というベンチマーク値からすると遅く見える。内訳のほとんどはフォントで、9.6 MB ある Noto Sans JP の TTF を解析して、使った字だけのサブセットを作る処理が毎回走っている。件数を増やしても 1 行あたりのコストは 70 µs 前後で、線形に伸びる。月次の帳票を夜間バッチで全社分出す、くらいの量なら、PDF 生成より DB クエリのほうが先に詰まる。

ファイルサイズは正直に書いておく。1 ページあたり約 115 KB ある。gpdf v1.0.17 はフォントと画像のストリームは圧縮するが、ページの描画命令は圧縮していない。全セルに罫線を引く帳票は描画命令が多いので、これがそのまま効く。10,000 行で 19 MB はメール添付には大きい。月次で分ける、罫線を外して template.TableStripe の縞模様にする、あたりが現実的な逃げ方になる。

割り切ったところ

セルの内側の余白。 v1.0.17 の c.Table にはセル内余白を指定するオプションがない。セル罫線を引くと、文字が線に貼り付く。サンプルでは table 関数の中で、揃える側に半角スペースを 1 つ足して逃がしている。美しくはない。が、動く。罫線を使わず縞模様だけにするなら、この問題自体が出ない。

ページ計と前頁繰越。 手書きの帳簿には、ページの最終行に「次頁へ繰越」、次のページの 1 行目に「前頁より繰越」と書く習慣がある。これをやるには、どの行がどのページに落ちるかを集計側が知っている必要がある。gpdf は改ページをレイアウト時に決めるので、ビルダー API からはその情報が取れない。1 ページの行数を固定して自分でページを切れば実現できるが、それは自動改ページを捨てるということでもある。うまいやり方はまだ見つかっていない。

法的要件。 この記事のコードは「帳簿の見た目をした PDF」を出すだけで、電子帳簿保存法の保存要件を満たすかどうかは別の話になる。会計システムで作成した帳簿はデータのまま保存するのが基本で、PDF は閲覧や提出のための出力という位置づけになることが多い。要件は 国税庁の電子帳簿保存法のページ と顧問税理士に確認してほしい。

FAQ

Go で帳票 PDF を出すのに、帳票ツールは必要ですか? 仕訳帳・元帳・試算表のような「表が主役」の帳票なら不要。gpdf の c.Table に [][]string を渡せば、罫線・列幅・右寄せ・自動改ページ・見出し行の繰り返しまで動く。gpdf は MIT ライセンスで外部依存ゼロなので、帳票ツールのランタイムライセンスも発生しない。

横向き (A4 横) にできますか? できる。template.WithPageSize(document.A4.Landscape()) に変える。摘要欄が長い仕訳帳は横向きのほうが読みやすい。列幅はパーセント指定なので、コードの他の部分は変えなくていい。

日本語フォントは何を使えばいいですか? サンプルは Noto Sans JP。IPAex ゴシックや源ノ角ゴシックも同じ手順で使える。template.WithFont で TTF を登録するだけで、使った字だけがサブセット埋め込みされる。詳しくは gpdf で日本語フォントを埋め込むには。

マイナスの金額を赤字や △ で出せますか? △ は yen 関数の中で文字列として付ければいい。セル単位で文字色を変えるオプションは c.Table にはないので、赤字にしたい場合は document.Table を直接組み立てる層に降りる。Go の PDF でテーブルを扱う に書いた。

長期保存用に PDF/A で出せますか? 出せる。gpdf.NewDocument に gpdf.WithPDFA() を渡すと PDF/A 準拠の出力になる。フォント埋め込みが必須になるが、日本語帳票ではどのみち埋め込んでいる。

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT ライセンス、依存ゼロ、CJK ネイティブ対応。

go get github.com/gpdf-dev/gpdf

⭐ GitHub で Star · ドキュメントを読む

次に読む

繰越行の件は、いい方法を思いついたら GitHub の issue に書いてほしい。