Генерация PDF и документов в Go: рабочие приёмы
Счёт на 200 позиций, договор с подстановкой данных, отчёт с графиками — три задачи, три разных подхода к PDF в Go. Где хватит библиотеки на чистом Go, а где придётся тащить в контейнер headless-браузер.

Задача звучит одинаково — «сгенерировать PDF», — но за ней прячутся три несовместимых по трудозатратам сценария. Простой счёт с таблицей рисуется на чистом Go за пару часов. Договор, где нужно точно сохранить вёрстку из шаблона Word, — уже другая история. А красивый отчёт с CSS-версткой и графиками проще собрать через headless Chrome, чем воевать с примитивами. Ниже — что выбирать под каждый случай, чем платите за каждый подход и где всё это ломается на проде.
Три подхода и их цена
Способов генерировать PDF в Go по сути три, и они принципиально разные не по синтаксису, а по тому, как вы описываете документ.
- Рисование примитивами — вы вручную ставите текст, линии и прямоугольники по координатам. Библиотеки вроде
gopdfиgo-pdf/fpdf(форк устаревшегоjung-kurt/gofpdf). Полный контроль, ноль внешних зависимостей, но каждая таблица и перенос строки — ваша ручная работа. - HTML → PDF через headless-браузер — вы верстаете документ в HTML/CSS и отдаёте его Chromium через протокол CDP (библиотека
chromedp). Вёрстка любой сложности, но в образ приезжает целый браузер. - Заполнение готовых шаблонов — для форм PDF (AcroForm) или для документов Word/Excel, которые потом конвертируются. Подходит, когда макет рисует не программист, а юрист или дизайнер.
Смешивать их в одном сервисе — нормально. Счета генерите примитивами, а маркетинговый отчёт — через Chromium. Плохо другое: тащить headless-браузер ради одного счёта с тремя строками.
Выбор подхода — это на 90% выбор того, кто рисует макет. Если верстает разработчик кодом — берите примитивы. Если макет живёт в виде HTML или Word-шаблона от непрограммиста — не переписывайте его в координаты, конвертируйте.
Сравнение
| Критерий | Примитивы (gopdf/fpdf) | HTML → Chromium (chromedp) | Заполнение шаблонов |
|---|---|---|---|
| Зависимости в образе | нет, чистый Go | Chromium (~300+ МБ) | зависит от формата |
| Сложная вёрстка, CSS, графики | больно | легко | ограничена шаблоном |
| Расход памяти на документ | низкий | высокий, отдельный процесс | средний |
| Кириллица | нужен свой шрифт | из коробки | зависит от шаблона |
| Кто правит макет | только разработчик | верстальщик | юрист/дизайнер |
Подход 1: примитивы для табличных документов
Первое, обо что спотыкаются все, — кириллица. Встроенные шрифты PDF латинские, русский текст выйдет пустыми квадратами или крякозябрами. Нужно подключить TTF-шрифт с поддержкой кириллицы (например, DejaVu Sans или любой лицензированный под ваш случай) и указать кодировку.
Минимальный счёт на go-pdf/fpdf (модуль github.com/go-pdf/fpdf):
package main
import (
"github.com/go-pdf/fpdf"
)
func main() {
pdf := fpdf.New("P", "mm", "A4", "")
// Подключаем TTF со встроенной кириллицей.
// Файл DejaVuSans.ttf должен лежать рядом или по указанному пути.
pdf.AddUTF8Font("dejavu", "", "DejaVuSans.ttf")
pdf.SetFont("dejavu", "", 12)
pdf.AddPage()
pdf.Cell(0, 10, "Счёт № 42 от 01.02.2025")
pdf.Ln(12)
// Шапка таблицы
pdf.SetFont("dejavu", "", 10)
pdf.CellFormat(100, 8, "Наименование", "1", 0, "L", false, 0, "")
pdf.CellFormat(40, 8, "Кол-во", "1", 0, "C", false, 0, "")
pdf.CellFormat(40, 8, "Сумма, ₽", "1", 1, "R", false, 0, "")
rows := []struct {
Name string
Qty int
Sum string
}{
{"Разработка API", 1, "120 000"},
{"Настройка CI", 3, "45 000"},
}
for _, r := range rows {
pdf.CellFormat(100, 8, r.Name, "1", 0, "L", false, 0, "")
pdf.CellFormat(40, 8, itoa(r.Qty), "1", 0, "C", false, 0, "")
pdf.CellFormat(40, 8, r.Sum, "1", 1, "R", false, 0, "")
}
if err := pdf.OutputFileAndClose("invoice.pdf"); err != nil {
panic(err)
}
}
func itoa(n int) string {
return strconv.Itoa(n)
}
Здесь CellFormat с параметром переноса строки (пятый аргумент 1) двигает курсор на новую строку — это ручная вёрстка таблицы. Библиотека сама не разобьёт длинный текст по ячейке и не перенесёт таблицу на новую страницу. Точный набор методов и их сигнатуры сверяйте по документации модуля, форки gofpdf расходятся в деталях.
Где ломается
Длинный текст, который не влезает в ячейку, вылезет за границу — фиксированная высота строки его не обрежет по-умному. Для многострочных ячеек нужен MultiCell, и тогда вы сами считаете, на сколько сдвинулись по вертикали. Перенос таблицы через границу страницы тоже на вас: проверяете текущую координату GetY(), и если места мало — вызываете AddPage() и перерисовываете шапку.
Подход 2: HTML → PDF через chromedp
Когда документ — это отчёт с колонками, фоновыми блоками, графиками и корпоративным стилем, рисовать его примитивами — самоистязание. Проще сверстать HTML, отдать в Chromium и попросить его напечатать в PDF. Библиотека chromedp (модуль github.com/chromedp/chromedp) управляет браузером по протоколу CDP.
package main
import (
"context"
"os"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
func main() {
html := `
Отчёт за февраль
Выручка: 340 000 ₽
`
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
// Общий таймаут, чтобы зависший рендер не держал процесс.
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var pdfBuf []byte
err := chromedp.Run(ctx,
// Грузим HTML как data-URL, чтобы не поднимать локальный сервер.
chromedp.Navigate("data:text/html,"+html),
chromedp.ActionFunc(func(ctx context.Context) error {
buf, _, err := page.PrintToPDF().
WithPrintBackground(true).
Do(ctx)
pdfBuf = buf
return err
}),
)
if err != nil {
panic(err)
}
os.WriteFile("report.pdf", pdfBuf, 0644)
}
Кириллица и любые веб-шрифты работают из коробки — их отрисовывает браузер. Точные имена опций PrintToPDF (поля, масштаб, ориентация) смотрите в спецификации CDP-домена page, они соответствуют параметрам печати Chromium.
Что учесть на проде
Chromium — это отдельный процесс со своей памятью. Под нагрузкой без ограничений он съест всё: каждый параллельный рендер поднимает вкладку, а зависшая вкладка не освобождается сама. Практические правила:
- Держите один экземпляр браузера и создавайте вкладки через
chromedp.NewContextот общего аллокатора, а не запускайте новый Chromium на каждый запрос. - Ограничивайте параллелизм рендеров семафором — иначе десять одновременных отчётов положат сервис по памяти.
- Всегда ставьте таймаут через
context.WithTimeout. Зависший рендер без таймаута — это утечка процесса. - В контейнере Chromium требует флаги вроде
--no-sandboxи достаточного размера/dev/shm, иначе падает с непонятной ошибкой. Конкретный набор флагов зависит от базового образа — сверяйте с документацией chromedp.
Подход 3: заполнение шаблонов Word и Excel
Договор, акт, коммерческое предложение обычно уже существуют в виде .docx, который согласовали юристы. Переписывать этот макет в координаты — путь к вечным правкам «подвиньте печать на 2 мм». Здесь работает подстановка данных в готовый шаблон.
Простейший вариант для .docx: файл — это ZIP с XML внутри. Заранее размечаете шаблон плейсхолдерами ({{ClientName}}), а в коде распаковываете, подставляете значения в XML и запаковываете обратно. Для табличных данных удобнее готовые библиотеки работы с OOXML — но проверяйте, что выбранный пакет поддерживается и не заброшен, экосистема здесь пёстрая.
Если на выходе нужен именно PDF, а не Word, то после подстановки документ конвертирует внешний инструмент — чаще всего LibreOffice в headless-режиме. Это переносит нагрузку с вашего Go-процесса на отдельный сервис, но добавляет ещё одну зависимость в инфраструктуру. Заполнение самих PDF-форм (AcroForm) — отдельная тема, здесь её не разворачиваем.
Когда что выбирать
- Счета, накладные, чеки, простые таблицы — примитивы. Нет внешних зависимостей, быстро, дёшево по памяти. Готовы платить ручной вёрсткой таблиц.
- Отчёты с дизайном, графиками, сложной сеткой — HTML через chromedp. Платите памятью и тяжёлым образом, но получаете любую вёрстку.
- Юридические документы с утверждённым макетом — заполнение шаблона. Макет правит не разработчик, вы только подставляете данные.
Что настроить дальше
Если пошли путём примитивов — вынесите генерацию таблицы в переиспользуемую функцию с автоматическим переносом на новую страницу по GetY(), иначе каждый новый документ будет копипастой. Если выбрали Chromium — соберите базовый образ с браузером один раз и мерьте RSS процесса под целевым параллелизмом: именно память, а не CPU, обычно упирается первой. Метрика, за которой стоит следить в проде, — время рендера одного документа и число одновременно живых вкладок Chromium.
Аудитор договоров: Word/PDF договоры: скрытые штрафы и пени, протокол разногласий.
Спросить за 20 ₽Источники: go-pdf/fpdf — репозиторий и документация, chromedp — репозиторий и примеры
Частые вопросы
Русский текст выходит квадратами или пустотой — почему?
А если у меня другая библиотека — форк gofpdf, а не go-pdf/fpdf?
Chromium в контейнере падает сразу при старте — что делать?
Сколько памяти закладывать на генерацию через headless-браузер?
Можно ли обойтись без Chromium, если нужен красивый PDF?
Как перенести длинную таблицу на следующую страницу при рисовании примитивами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.