전체 글

gpdf 푸터에 페이지 번호 X / Y를 한 줄로 표시하려면?

PageNumberPlaceholder와 TotalPagesPlaceholder를 c.Text 하나에 넣으면 됩니다. v1.0.14+는 치환 후 정렬도 유지합니다.

2026년 11월 12일 게시
저자: Taiki Noda

질문을 바꿔 말하면

푸터에 Page 3 of 12라고 표시하고 싶습니다. 한 줄, 문자열 하나로, 푸터의 다른 요소처럼 오른쪽 정렬해서요. gpdf에는 c.PageNumber()와 c.TotalPages()가 있지만 각각 독립된 요소입니다. 열 네 개를 이어 붙이지 않고 글자와 두 숫자를 한 줄에 넣으려면 어떻게 해야 할까요?

TL;DR

두 플레이스홀더는 export된 문자열 상수입니다. 평범한 문자열 중간에 넣으면 됩니다.

c.Text("Page "+document.PageNumberPlaceholder+" of "+document.TotalPagesPlaceholder,
	template.AlignRight())

gpdf는 페이지 분할 후 모든 페이지에서 이를 치환합니다. gpdf v1.0.14 이상에서는 치환 후 줄을 다시 측정하므로, 페이지 번호가 9에서 10으로 바뀌어도 오른쪽 정렬이 유지됩니다.

코드

400행짜리 테이블이 A4 9페이지로 넘어가는 문서에 푸터를 붙인 예입니다. gpdf v1.0.17에서 확인했습니다.

package main

import (
	"fmt"
	"log"
	"os"

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

func main() {
	pageOf := "Page " + document.PageNumberPlaceholder +
		" of " + document.TotalPagesPlaceholder

	doc := template.New(
		template.WithPageSize(document.A4),
		template.WithMargins(document.UniformEdges(document.Mm(20))),
	)

	doc.Footer(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(pageOf,
					template.AlignRight(),
					template.FontSize(9),
					template.TextColor(pdf.Gray(0.4)),
				)
			})
		})
	})

	rows := make([][]string, 0, 400)
	for i := 1; i <= 400; i++ {
		rows = append(rows, []string{fmt.Sprint(i), fmt.Sprintf("Line item %03d", i)})
	}

	page := doc.AddPage()
	page.AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			c.Table([]string{"#", "Description"}, rows)
		})
	})

	out, err := doc.Generate()
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile("page-x-of-y.pdf", out, 0o644); err != nil {
		log.Fatal(err)
	}
}

pdftotext -layout page-x-of-y.pdf -를 실행하면 페이지마다 Page 1 of 9부터 Page 9 of 9까지 출력됩니다. 따로 설정할 것은 없습니다.

왜 동작하는가

c.PageNumber()에 특별한 마법은 없습니다. 구현은 "내용이 document.PageNumberPlaceholder인 document.Text를 추가한다"가 전부입니다. c.TotalPages()도 상수만 다를 뿐 같습니다. 플레이스홀더는 NUL 바이트로 감싼 센티널 문자열("\x00GPDF_PAGE\x00", "\x00GPDF_TOTAL\x00")이며, 실제 텍스트와 절대 겹치지 않도록 고른 값입니다.

치환은 페이지네이터가 전체 페이지 수를 정한 뒤에 일어납니다. 모든 페이지에 배치된 텍스트 노드를 순회하면서 두 플레이스홀더에 대해 strings.ReplaceAll을 실행합니다. 노드에 플레이스홀더만 들어 있든, 문장 중간에 하나 끼어 있든 상관하지 않습니다. 그래서 "Page " + PageNumberPlaceholder + " of " + TotalPagesPlaceholder도 전용 메서드와 똑같이 처리됩니다.

헤더와 푸터 노드는 한 번만 레이아웃되어 물리 페이지 간에 공유되므로, 리졸버는 페이지별 복사본을 다룹니다. 3페이지 푸터에는 3, 4페이지에는 4가 들어가고, *template.Document 자체는 변경되지 않으므로 두 번째로 렌더링해도 플레이스홀더가 그대로 남아 있습니다.

이 마지막 부분은 놓치기 쉽습니다. 같은 방법이 본문에서도 통하므로 표지 문구에 쓸 수 있습니다.

c.Text(fmt.Sprintf("This report has %s pages.", document.TotalPagesPlaceholder))

같은 9페이지 문서의 1페이지에 This report has 9 pages.가 출력됩니다.

예전 글에서 열 네 개를 쓰라고 한 이유

5월에 쓴 Go PDF에서 페이지 번호, 헤더, 푸터를 제대로 넣기에서는 한 줄짜리 "Page X of Y"를 아쉬운 점으로 꼽고 열 네 개로 나누는 우회 방법을 소개했습니다. 당시 gpdf에서는 맞는 조언이었고, 이유는 측정 순서에 있습니다.

레이아웃 엔진은 페이지 수가 정해지기 전에 텍스트의 줄바꿈과 정렬을 끝냅니다. v1.0.13까지는 원래의 센티널 문자열이 들어간 상태로 이 작업을 했습니다. \x00GPDF_TOTAL\x00은 11바이트라서, 엔진은 Page 1 of 9를 실제보다 훨씬 넓다고 판단했습니다. 위 프로그램을 v1.0.13으로 다시 돌려서 얼마나 어긋나는지 확인했습니다.

푸터 레이아웃v1.0.13v1.0.17
12칸 열, 오른쪽 정렬오른쪽 끝이 x = 424.6 pt — 여백까지 114 pt 모자람9페이지 모두 오른쪽 끝이 x = 538.6 pt(여백 위치)
3칸 열, 오른쪽 정렬Page 1 of가 한 줄, 9는 다음 줄로 넘어감Page 1 of 9가 한 줄
두 자리 페이지(32페이지 실행)—Page 9 of 32에서 Page 10 of 32까지 오른쪽 끝이 538.6 pt로 유지

114포인트는 40 mm입니다. 오른쪽 정렬 푸터에서 이 정도면 커닝 문제처럼 보이지 않습니다. 누가 Tab 키를 누른 것처럼 보입니다. 더 좁은 열에서는 상황이 더 나빴는데, 이를 고친 커밋에 따르면 줄바꿈 처리기가 센티널 자체를 쪼개서 ?GP나 DF 같은 조각이 그대로 인쇄되었습니다.

v1.0.14(커밋 b58626a)는 두 가지를 바꿨습니다. 레이아웃 단계에서는 각 플레이스홀더를 숫자 한 자리("0")로 측정하고 줄바꿈한 뒤, 각 줄의 내용에 플레이스홀더를 다시 복원합니다. 그다음 ResolvePageNumbersWithFonts가 치환된 모든 줄을 실제 폰트로 다시 측정하고, 가운데 정렬과 오른쪽 정렬 줄을 기준점으로 되돌립니다. Page 10 of 32가 한 자리 더 넓은데도 Page 9 of 32와 같은 x에서 끝나는 것은 이 두 번째 단계 덕분입니다.

v1.0.13 이하에 머물러야 한다면 예전 글의 다중 열 레이아웃이 여전히 동작합니다. 플레이스홀더가 각자 자기 열에 단독으로 들어가기 때문입니다. 그렇지 않다면 업그레이드하세요.

go get github.com/gpdf-dev/gpdf@latest

변형

슬래시 형식. document.PageNumberPlaceholder + " / " + document.TotalPagesPlaceholder는 3 / 12가 됩니다.

fmt.Sprintf. 플레이스홀더는 평범한 문자열이므로 %s를 씁니다. %d는 쓰지 마세요. 아직 숫자가 없습니다. 플레이스홀더를 쓰는 이유가 바로 그것입니다.

fmt.Sprintf("Page %s of %s", document.PageNumberPlaceholder, document.TotalPagesPlaceholder)

한국어 등 CJK. 해당 문자를 포함하는 폰트를 등록하고 텍스트에 지정하세요. PageNumberPlaceholder + " / " + TotalPagesPlaceholder + " 페이지" 같은 형식도 똑같이 치환됩니다. 폰트 서브셋에는 실제로 쓴 글리프만 들어갑니다.

JSON과 Go 템플릿. 계속 {"type": "pageNumber"}와 {"type": "totalPages"}를 별도 요소로 쓰세요. v1.0.17에서는 JSON 문자열에 \u0000GPDF_PAGE\u0000을 직접 써도 동작합니다. 하지만 상수의 이름이 API이고 값은 구현 세부 사항입니다. 프로덕션 템플릿을 그 값이 바뀌지 않는다는 데 걸지는 않겠습니다.

아직 할 수 없는 것은 페이지에 따른 분기입니다. 푸터 클로저는 자신이 1페이지를 렌더링하는지 알지 못하므로, "표지에는 번호를 넣지 않기"는 다른 방법이 필요합니다. 그건 별도 레시피로 다룹니다.

관련 레시피

gpdf 사용해 보기

gpdf는 PDF를 생성하는 Go 라이브러리입니다. MIT 라이선스, 외부 의존성 제로, 네이티브 CJK 지원.

go get github.com/gpdf-dev/gpdf

⭐ GitHub에서 Star · 문서 읽기