Go로 회계 장부 PDF 만들기 — 분개장·총계정원장·시산표
Go로 회계 장부 PDF를 생성한다. 분개 슬라이스 하나로 분개장·총계정원장·시산표를 만드는 gpdf 전체 코드. 괘선 테이블과 자동 페이지 분할 포함.
TL;DR
분개 슬라이스 하나만 있으면 같은 Go 프로그램에서 분개장, 총계정원장, 시산표를 모두 만들 수 있다. gpdf v1.0.17 기준으로 main.go 전체가 240줄이다. 괘선 테이블, 오른쪽 정렬된 금액, 페이지가 넘어갈 때 반복되는 헤더 행, 1 / 3 형식의 페이지 번호가 포함된다. Chromium도, CGO도, 리포팅 도구 라이선스도 필요 없다.
한 달 치 분개장(67건)은 2페이지이고 Apple M1에서 약 9 ms가 걸린다. 10,000건이면 170페이지에 약 0.7초다.
장부는 표가 아니라 집계다
Go로 회계 장부를 출력하는 방법을 물을 때 첫 질문은 대개 괘선을 어떻게 긋느냐다. 일이 많은 곳은 거기가 아니다.
분개장, 총계정원장, 시산표는 서로 다른 문서처럼 보이지만 같은 데이터에서 나온다. 분개장은 분개를 날짜순으로 나열한다. 총계정원장은 같은 분개를 계정별로 다시 묶고 잔액을 누적한다. 시산표는 계정 하나를 한 행으로 접는다. 셋의 차이는 GROUP BY가 다르다는 것뿐이다.
그래서 설계는 데이터 타입 하나, 집계 함수 셋, 렌더링 함수 하나가 된다. 렌더링 함수는 gpdf의 c.Table이고 얇다. 이 글이 분량의 대부분을 집계 쪽에 쓰는 이유다.
Go로 회계, 청구, 경비 시스템을 만들다가 요구사항에서 "장부 출력"이라는 말을 발견한 사람을 위한 글이다.
샘플은 일본식 부기를 쓴다. 장부 이름은 仕訳帳(분개장), 総勘定元帳(총계정원장), 合計残高試算表(합계잔액시산표)이고 계정과목명도 일본어다. 구조는 어디서나 쓰는 복식부기와 같으므로 한국 장부에도 그대로 적용된다. 일본어 텍스트를 그대로 둔 덕분에 CJK 출력에 폰트 등록 외에 특별한 처리가 필요 없다는 점도 확인할 수 있다.
데이터 모델: 분개 한 종류
type Entry struct {
Date, Debit, Credit, Memo string
Amount int64
}
분개는 차변 계정, 대변 계정, 금액이다. 금액은 엔 단위 정수 int64다. float64를 쓰면 시산표가 1 어긋나는 날 밤새 그 1을 찾게 된다. 0.1 + 0.2가 0.3이 아닌 세계에서는 차변과 대변이 같은지 확인하는 검사가 성립하지 않는다.
계정은 두 개의 테이블로 기술한다.
var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}
var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}
accounts는 표시 순서다. 현금, 보통예금, 외상매출금, 외상매입금, 자본금, 매출, 매입, 임차료 순이다. 원장과 시산표가 이 순서를 따른다. debitSide는 차변에서 증가하는 계정, 즉 자산과 비용을 표시한다. 부채, 자본, 수익은 대변에서 증가한다. 원장에서 잔액 컬럼을 계산하려면 이 방향이 필요하다.
이것은 단순화한 모델이다. 실제 분개가 항상 일대일인 것은 아니다. 급여 분개는 차변 한 줄에 대변 세 줄이다. 보통예금, 소득세 예수금, 사회보험료 예수금이다. 복합 분개를 지원하려면 Entry를 전표 번호와 라인 슬라이스로 바꾼다. 그러면 집계 함수가 라인을 순회하게 되고 렌더링 코드는 전혀 바뀌지 않는다.
전체 코드
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는 분개 하나다. 금액은 엔 단위 정수 int64이며 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에는 셀 패딩 옵션이 없어 텍스트가 셀 테두리에
// 닿는다. 정렬 방향 쪽에 공백 하나를 붙여 괘선에서 떼어 놓는다.
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
// 분개장(仕訳帳): 분개 하나당 한 행, 날짜순.
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("out of balance: totals %d/%d balances %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 쿼리다. 데모용으로 한 달 치 분개를 생성한다.
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가 만들어진다. 아래는 총계정원장의 첫 페이지다.

분개장: 너비와 정렬을 지정한다
분개장은 셋 중 가장 단순하다. 분개 하나가 한 행이 되고 마지막에 합계 행이 붙는다.
table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})
지정하는 것은 두 가지다. template.ColumnWidths는 합이 100이 되는 퍼센트로 너비를 받는다. template.ColumnAlign은 컬럼마다 정렬을 하나씩 받는다. 금액은 오른쪽, 나머지는 왼쪽이다. ColumnAlign은 헤더 행에도 적용되므로 제목도 숫자 위에 오른쪽 정렬로 놓인다.
천 단위 구분 기호는 값이 테이블에 들어가기 전에 yen 함수가 붙인다. gpdf에는 숫자 포매팅이 없다. c.Table은 [][]string을 받고 각 셀의 포매팅은 호출하는 쪽의 일이다. 0이 빈 문자열이 되는 것은 원장에서 차변 금액이 있는 행의 대변 셀은 비어 있어야 하기 때문이다. 0이 가득 인쇄된 장부는 읽기 어렵다.
합계 행은 차변과 대변에 같은 total을 넣는다. 일대일 분개에서는 정의상 맞다. 복합 분개로 확장한다면 양쪽을 따로 합산한다.
총계정원장: 전기와 누적 잔액
원장은 분개장을 계정별로 전기한 것이다. 어떤 분개가 외상매출금을 차변에, 매출을 대변에 기록하면 외상매출금 원장의 차변 컬럼에 한 번, 매출 원장의 대변 컬럼에 한 번 나타난다. 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개에 분개 100,000건이라면 먼저 분개를 map[string][]Entry에 나눠 담는다. 그 규모라면 어차피 집계는 SQL에서 할 가능성이 높고 Go는 계정별로 이미 묶인 행을 받는다.
잔액 컬럼은 debitSide로 방향을 정한다.
if debitSide[acct] {
bal += dr - cr
} else {
bal += cr - dr
}
보통예금은 차변 잔액 계정이므로 입금(차변)에서 늘고 출금(대변)에서 준다. 외상매입금은 대변 잔액 계정이므로 반대다. 이 분기를 빼면 잔액이 점점 더 큰 음수가 되는 외상매입금 원장이 나온다. 숫자는 산술적으로 맞는데도 회계팀은 돌려보낸다. 그래서 설명하기 까다로운 버그에 속한다.
여담으로, 차변이 왼쪽이고 대변이 오른쪽인 데에는 논리적 이유가 없다. 15세기 이탈리아에서 복식부기가 자리 잡은 뒤로 살아남은 관습이고 외워야 한다. 용어 자체도 도움이 되지 않는다. "차변"과 "대변"은 상대방의 입장을 기술한 말이라 대부분의 입문자가 기대하는 것과 반대다. 변수 이름을 dr과 cr로 지으면 적어도 코드가 혼란을 더하지는 않는다.
레이아웃 쪽에서는 계정 하나가 page.AutoRow 하나를 추가한다.
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))
})
})
계정명, 테이블, 여백을 여덟 번 쌓는다. 이 코드 어디에서도 페이지가 끝나는 위치를 계산하지 않는다.
시산표: 렌더링 전에 실패시킨다
시산표는 계정별 차변 합계와 대변 합계를 보여 주고 그 차액을 해당하는 쪽에 적는다. 관례상 계정과목명이 가운데에 오고 차변이 왼쪽, 대변이 오른쪽이므로 컬럼은 차변 잔액, 차변 합계, 계정과목, 대변 합계, 대변 잔액 순이다.
이 장부에서 가장 중요한 줄은 테이블을 그리기 전에 있다.
if sumDr != sumCr || balDr != balCr {
log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
}
시산표는 전기 오류를 잡기 위해 존재한다. 차변 합계와 대변 합계가 다르면 어딘가의 분개가 깨져 있다. 대차가 맞지 않는 PDF를 보기 좋게 조판할 이유가 없다. 그 대신 멈춘다.
샘플 데이터에서 합계는 11,269,600엔, 잔액은 7,389,600엔이고 양쪽이 일치한다. 일대일 분개만 있으면 일치할 수밖에 없다. 이 세 줄은 데이터가 데이터베이스에서 오는 순간부터 의미를 갖는다. 프로덕션에서는 log.Fatalf를 호출하지 말고 에러를 반환해 어느 계정이 어긋났는지 알 수 있게 한다.
페이지 분할: 할 일이 없다
위 코드에는 페이지 분할 로직이 없다. 행을 세지도 않고 남은 높이를 보지도 않는다.
테이블이 남은 공간에 들어가지 않으면 gpdf의 레이아웃 엔진이 본문을 나누고 다음 페이지 맨 위에 헤더 행을 다시 그린다. 분개장 2페이지는 헤더 행으로 시작하고, 원장에서 이어지는 외상매출금 테이블도 마찬가지다. doc.Header의 장부 제목과 기간, doc.Footer의 페이지 번호는 모든 페이지에 나온다. 동작 원리는 테이블을 여러 페이지에 걸쳐 출력하려면?에서 설명한다.
페이지 번호는 평범한 문자열 안에 든 플레이스홀더 두 개다.
c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder, ...)
전체 페이지 수는 페이지네이션이 끝난 뒤에 채워진다. gpdf 푸터에 페이지 번호 X / Y를 한 줄로 표시하려면?을 참고한다.
측정값
이 수치는 분개장만 대상으로 하며, 생성한 매출 분개의 건수를 바꿔 가며 측정했다. Apple M1, Go 1.25.2, gpdf v1.0.17. 시간은 폰트 바이트가 메모리에 올라온 시점부터 PDF가 디스크에 기록될 때까지다.
| 분개 수 | 페이지 | 시간 | 파일 크기 |
|---|---|---|---|
| 68 | 2 | 8.8 ms | 146 KB |
| 1,000 | 17 | 68 ms | 1.9 MB |
| 10,000 | 170 | ~0.7 s | 19 MB |
68건에 8.8 ms는 gpdf의 단일 페이지 벤치마크 수치인 13 µs와 비교하면 느려 보인다. 그 시간의 대부분은 폰트다. Noto Sans JP는 9.6 MB짜리 TTF이고 실행할 때마다 파싱과 서브셋 생성을 거친다. 그 밖에는 행 하나가 늘 때마다 약 70 µs가 들고 증가는 선형이다. 회사 전체의 월별 장부를 만드는 야간 배치라면 PDF 생성보다 데이터베이스 쿼리가 먼저 병목이 된다.
파일 크기는 있는 그대로 적어 둔다. 페이지당 약 115 KB다. gpdf v1.0.17은 폰트와 이미지 스트림은 압축하지만 페이지 그리기 연산자는 압축하지 않으며, 모든 셀에 테두리가 있는 장부에는 연산자가 많다. 10,000행에 19 MB는 이메일 첨부로는 너무 크다. 월별로 나누거나, 괘선을 버리고 template.TableStripe 줄무늬 행을 쓰는 것이 현실적인 우회 방법이다.
포기한 것
셀 패딩. v1.0.17의 c.Table에는 셀 안쪽 패딩 옵션이 없다. 셀 테두리를 쓰면 텍스트가 선에 닿는다. 샘플의 table 함수는 각 셀의 정렬 방향 쪽에 공백 하나를 붙인다. 깔끔하지는 않지만 동작한다. 테두리 대신 줄무늬를 쓰면 이 문제는 생기지 않는다.
페이지 소계와 이월 행. 종이 장부는 전통적으로 각 페이지를 "차면이월" 행으로 끝내고 다음 페이지를 "전면이월" 행으로 시작한다. 그렇게 하려면 집계 코드가 어느 행이 어느 페이지에 놓이는지 알아야 한다. gpdf는 페이지 분할을 레이아웃 시점에 결정하므로 빌더 API로는 알 수 없다. 페이지당 행 수를 고정하고 직접 페이지를 나눌 수는 있지만 그러면 자동 페이지네이션을 포기해야 한다. 좋은 방법은 아직 찾지 못했다.
법적 요건. 이 코드는 회계 장부처럼 보이는 PDF를 만든다. 그것이 해당 국가의 장부 보존 규정을 충족하는지는 별개의 문제다. 예를 들어 일본에서는 회계 시스템에서 작성한 장부를 일반적으로 전자장부보존법에 따라 데이터로 보존하고, PDF는 열람이나 제출을 위한 출력물이다. 한국의 전자문서·장부 보존 규정 역시 따로 확인해야 하는 문제다. 일본 국세청 안내(일본어)나 담당 회계사에게 확인한다.
FAQ
Go로 회계 장부 PDF를 만들려면 리포팅 도구가 필요한가?
분개장, 원장, 시산표처럼 테이블 중심인 장부라면 필요 없다. gpdf의 c.Table에 [][]string을 넘기면 테두리, 컬럼 너비, 오른쪽 정렬, 자동 페이지 분할, 헤더 행 반복이 된다. gpdf는 MIT 라이선스에 외부 의존성이 0이므로 런타임 라이선스도 없다.
가로 방향으로 출력할 수 있나?
된다. 옵션을 template.WithPageSize(document.A4.Landscape())로 바꾼다. 적요가 긴 분개장은 가로 방향이 읽기 좋다. 컬럼 너비가 퍼센트라서 코드의 다른 부분은 바뀌지 않는다.
일본어가 아닌 텍스트에서도 동작하나?
동작한다. 문자열을 바꾸고 해당 문자를 지원하는 폰트를 template.WithFont로 등록한다. 일본어라면 Noto Sans JP, IPAex Gothic, Source Han Sans 모두 같은 방식으로 동작한다. gpdf에서 일본어 폰트를 임베드하려면?을 참고한다.
음수 금액을 빨간색이나 괄호로 표시할 수 있나?
괄호나 마이너스 기호는 문자열 포매팅 문제이므로 yen에서 처리한다. c.Table에는 셀 단위 글자색 옵션이 없다. 빨간 숫자가 필요하면 Go PDF 테이블: 컬럼 너비, 줄무늬, 페이지 분할에서 설명하는 대로 document.Table을 직접 조립한다.
장기 보존용으로 PDF/A를 출력할 수 있나?
된다. gpdf.NewDocument에 gpdf.WithPDFA()를 넘긴다. PDF/A는 폰트 임베드를 요구하는데 CJK 장부는 이미 그렇게 되어 있다.
gpdf 시작하기
gpdf은 Go 용 PDF 라이브러리다. MIT, 의존성 0, 네이티브 CJK.
go get github.com/gpdf-dev/gpdf
이어 읽기
- Go PDF 테이블: 컬럼 너비, 줄무늬, 페이지 분할 —
c.Table의 모든 옵션과document.Table로 내려가야 하는 경우. - Go로 50줄 이하에서 인보이스 PDF 생성하기 — 장부보다 먼저 만들게 되는 한 페이지짜리 문서.
- Go로 일본어 PDF 만들기 — 2026 결정판 가이드 — 폰트 선택부터 두부 문자 해결까지.
이월 행을 깔끔하게 처리하는 방법을 찾았다면 GitHub issue를 열어 주기 바란다.