PDF Rendering with chromedp
Complete guide for rendering HTML templates to PDF using headless Chrome.
Overview
PDF generation uses chromedp (headless Chrome) to render HTML → PDF. This approach ensures:
- ✅ WYSIWYG - Preview matches final PDF exactly (same renderer)
- ✅ Full CSS support - Flexbox, grid, custom fonts, etc.
- ✅ Print-specific CSS -
@page,@media print, page breaks - ✅ No layout bugs - Chrome rendering is battle-tested
- ✅ Embedded images - Base64 or S3 URLs work seamlessly
Architecture
┌─────────────────────────────────────────────────────────┐
│ PDF Rendering Pipeline │
└─────────────────────────────────────────────────────────┘
1. DATA FETCHING
├─ Load form (values, fields)
├─ Load patient, specialist, appointment, organization
└─ Build TemplateData struct
2. TEMPLATE MERGING
├─ Load pdf_template (HTML + CSS)
├─ Parse Go template
├─ Execute template with TemplateData
└─ Generate final HTML string
3. PDF RENDERING
├─ Launch chromedp context
├─ Navigate to data URI (HTML)
├─ Wait for page load
├─ Print to PDF (with options)
└─ Return PDF bytes
4. STORAGE
├─ Upload PDF to S3
├─ Create appointment_documents record
└─ Return signed URLTemplate Data Structure
go
// TemplateData is passed to Go templates during rendering
type TemplateData struct {
// Document metadata
DocumentID int64 `json:"document_id"`
DocumentType string `json:"document_type"`
GeneratedAt time.Time `json:"generated_at"`
// Organization
Organization Organization `json:"organization"`
// Patient
Patient Patient `json:"patient"`
// Specialist
Specialist Specialist `json:"specialist"`
// Appointment
Appointment *Appointment `json:"appointment,omitempty"`
// Form data
FormValues map[string]interface{} `json:"form_values"`
FormFields []FormField `json:"form_fields"`
}
type Organization struct {
ID int64 `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Phone string `json:"phone"`
Email string `json:"email"`
Website string `json:"website"`
LogoURL string `json:"logo_url"`
}
type Patient struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
Birthdate time.Time `json:"birthdate"`
Age int `json:"age"` // Calculated from birthdate
}
type Specialist struct {
ID int64 `json:"id"`
Name string `json:"name"`
Title string `json:"title"` // "MD, Physiotherapist"
LicenseNumber string `json:"license_number"`
SignatureURL string `json:"signature_url"`
}
type Appointment struct {
ID int64 `json:"id"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
ServiceName string `json:"service_name"`
Duration int `json:"duration"` // minutes
}
type FormField struct {
CustomFieldID int64 `json:"custom_field_id"`
Label string `json:"label"`
Value interface{} `json:"value"`
IsPrivate bool `json:"is_private"`
}Go Template Merging
go
package pdftemplate
import (
"bytes"
"context"
"fmt"
"html/template"
"time"
)
type TemplateRenderer struct {
db *pgxpool.Pool
logger *slog.Logger
}
// MergeTemplate executes Go template with data
func (r *TemplateRenderer) MergeTemplate(
ctx context.Context,
templateID int64,
formID int64,
) (string, error) {
// 1. Load template
tmpl, err := r.loadTemplate(ctx, templateID)
if err != nil {
return "", fmt.Errorf("load template: %w", err)
}
// 2. Load form and related entities
data, err := r.loadTemplateData(ctx, formID)
if err != nil {
return "", fmt.Errorf("load template data: %w", err)
}
// 3. Parse and execute template
t, err := template.New("pdf").
Funcs(templateFuncs()). // Custom template functions
Parse(tmpl.TemplateHTML)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
// 4. Inject CSS
html := buf.String()
if tmpl.TemplateCSS != "" {
html = injectCSS(html, tmpl.TemplateCSS)
}
return html, nil
}
// Custom template functions
func templateFuncs() template.FuncMap {
return template.FuncMap{
"formatDate": func(t time.Time, layout string) string {
return t.Format(layout)
},
"formatCurrency": func(amount float64) string {
return fmt.Sprintf("€%.2f", amount)
},
"nl2br": func(text string) template.HTML {
return template.HTML(strings.ReplaceAll(text, "\n", "<br>"))
},
"safe": func(s string) template.HTML {
return template.HTML(s)
},
}
}
// Load form and related entities
func (r *TemplateRenderer) loadTemplateData(
ctx context.Context,
formID int64,
) (*TemplateData, error) {
var data TemplateData
// Load form
var form Form
err := r.db.QueryRow(ctx, `
SELECT
f.id,
f.patient_person_id,
f.appointment_id,
f.form_template_id,
f.organization_id,
f.values,
f.fields
FROM forms f
WHERE f.id = $1
AND f.status = 'signed'
`, formID).Scan(
&form.ID,
&form.UserID,
&form.AppointmentID,
&form.FormTemplateID,
&form.OrganizationID,
&form.Values,
&form.Fields,
)
if err != nil {
return nil, fmt.Errorf("load form: %w", err)
}
// Load organization
data.Organization, err = r.loadOrganization(ctx, form.OrganizationID)
if err != nil {
return nil, err
}
// Load patient
var patientID int64
err = r.db.QueryRow(ctx, `
SELECT id FROM patients WHERE user_id = $1
`, form.UserID).Scan(&patientID)
if err != nil {
return nil, err
}
data.Patient, err = r.loadPatient(ctx, patientID)
if err != nil {
return nil, err
}
// Load specialist (if appointment exists)
if form.AppointmentID != nil {
data.Appointment, err = r.loadAppointment(ctx, *form.AppointmentID)
if err != nil {
return nil, err
}
data.Specialist, err = r.loadSpecialist(ctx, data.Appointment.SpecialistID)
if err != nil {
return nil, err
}
}
// Parse form values and fields
data.FormValues = form.Values
data.FormFields = parseFormFields(form.Fields, form.Values)
// Document metadata
data.DocumentID = 0 // Will be set after document creation
data.DocumentType = "report"
data.GeneratedAt = time.Now()
return &data, nil
}
func parseFormFields(fields []FormField, values map[string]interface{}) []FormField {
result := make([]FormField, 0, len(fields))
for _, field := range fields {
fieldKey := fmt.Sprintf("field_%d", field.CustomFieldID)
value := values[fieldKey]
result = append(result, FormField{
CustomFieldID: field.CustomFieldID,
Label: field.Label,
Value: value,
IsPrivate: field.IsPrivate,
})
}
return result
}chromedp PDF Generation
go
package pdftemplate
import (
"context"
"fmt"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
type PDFRenderer struct {
logger *slog.Logger
}
// RenderPDF generates PDF from HTML using headless Chrome
func (r *PDFRenderer) RenderPDF(
ctx context.Context,
html string,
options PDFOptions,
) ([]byte, error) {
// Create chromedp context with timeout
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Allocate browser context
allocCtx, cancel := chromedp.NewContext(ctx)
defer cancel()
var pdfBuf []byte
// Build data URI from HTML
dataURI := fmt.Sprintf("data:text/html,%s", html)
// Run chromedp tasks
err := chromedp.Run(allocCtx,
// Navigate to data URI
chromedp.Navigate(dataURI),
// Wait for page to load
chromedp.WaitReady("body", chromedp.ByQuery),
// Wait a bit for fonts/images to load
chromedp.Sleep(500*time.Millisecond),
// Print to PDF
chromedp.ActionFunc(func(ctx context.Context) error {
var err error
pdfBuf, _, err = page.PrintToPDF().
WithPrintBackground(true).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(options.PaperHeight).
WithMarginTop(options.MarginTop).
WithMarginRight(options.MarginRight).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithPreferCSSPageSize(false).
Do(ctx)
return err
}),
)
if err != nil {
return nil, fmt.Errorf("chromedp run: %w", err)
}
return pdfBuf, nil
}
type PDFOptions struct {
PaperWidth float64 // inches
PaperHeight float64
MarginTop float64
MarginRight float64
MarginBottom float64
MarginLeft float64
}
// DefaultA4Options returns A4 portrait with 20mm margins
func DefaultA4Options() PDFOptions {
return PDFOptions{
PaperWidth: 8.27, // A4 width in inches
PaperHeight: 11.69, // A4 height in inches
MarginTop: 0.79, // 20mm in inches
MarginRight: 0.79,
MarginBottom: 0.79,
MarginLeft: 0.79,
}
}
// ParseLayoutConfig converts template layout_config JSONB to PDFOptions
func ParseLayoutConfig(config map[string]interface{}) PDFOptions {
opts := DefaultA4Options()
// Page size
pageSize, _ := config["pageSize"].(string)
switch pageSize {
case "A4":
opts.PaperWidth = 8.27
opts.PaperHeight = 11.69
case "Letter":
opts.PaperWidth = 8.5
opts.PaperHeight = 11
case "Legal":
opts.PaperWidth = 8.5
opts.PaperHeight = 14
}
// Orientation
orientation, _ := config["orientation"].(string)
if orientation == "landscape" {
opts.PaperWidth, opts.PaperHeight = opts.PaperHeight, opts.PaperWidth
}
// Margins (mm to inches)
if margins, ok := config["margins"].(map[string]interface{}); ok {
if top, ok := margins["top"].(float64); ok {
opts.MarginTop = top / 25.4 // mm to inches
}
if right, ok := margins["right"].(float64); ok {
opts.MarginRight = right / 25.4
}
if bottom, ok := margins["bottom"].(float64); ok {
opts.MarginBottom = bottom / 25.4
}
if left, ok := margins["left"].(float64); ok {
opts.MarginLeft = left / 25.4
}
}
return opts
}Complete Rendering Flow
go
package pdftemplate
type PDFService struct {
renderer *TemplateRenderer
pdfRenderer *PDFRenderer
s3Client *s3.Client
docService *documents.Service
logger *slog.Logger
}
// GeneratePDF - Complete end-to-end PDF generation
func (s *PDFService) GeneratePDF(
ctx context.Context,
templateID int64,
formID int64,
) (*Document, error) {
s.logger.Info("generating PDF",
"template_id", templateID,
"form_id", formID,
)
// 1. Merge template with data
html, err := s.renderer.MergeTemplate(ctx, templateID, formID)
if err != nil {
return nil, fmt.Errorf("merge template: %w", err)
}
// 2. Load PDF options from template layout_config
tmpl, err := s.renderer.loadTemplate(ctx, templateID)
if err != nil {
return nil, err
}
pdfOptions := ParseLayoutConfig(tmpl.LayoutConfig)
// 3. Render HTML → PDF
pdfBytes, err := s.pdfRenderer.RenderPDF(ctx, html, pdfOptions)
if err != nil {
return nil, fmt.Errorf("render PDF: %w", err)
}
// 4. Upload to S3
s3Key := fmt.Sprintf("org-%d/documents/%s_%d.pdf",
tmpl.OrganizationID,
tmpl.TemplateType,
time.Now().Unix(),
)
err = s.s3Client.PutObject(ctx, s3Key, pdfBytes, "application/pdf")
if err != nil {
return nil, fmt.Errorf("upload to S3: %w", err)
}
// 5. Create document record
doc, err := s.docService.Create(ctx, documents.CreateInput{
FormID: formID,
DocumentType: tmpl.TemplateType,
S3Key: s3Key,
PDFTemplateID: templateID,
PDFTemplateVersion: tmpl.Version,
})
if err != nil {
return nil, fmt.Errorf("create document: %w", err)
}
s.logger.Info("PDF generated successfully",
"document_id", doc.ID,
"s3_key", s3Key,
)
return doc, nil
}Preview Implementation
go
// PreviewPDF generates preview HTML or PDF with sample data
func (s *PDFService) PreviewPDF(
ctx context.Context,
templateID int64,
format string, // "html" or "pdf"
) ([]byte, error) {
// Load template
tmpl, err := s.renderer.loadTemplate(ctx, templateID)
if err != nil {
return nil, err
}
// Use sample data
data := &TemplateData{
DocumentID: 0,
DocumentType: tmpl.TemplateType,
GeneratedAt: time.Now(),
Organization: Organization{
Name: "Sample Clinic",
Address: "123 Main Street, Bucharest",
Phone: "+40 21 123 4567",
Email: "[email protected]",
LogoURL: "https://via.placeholder.com/150",
},
Patient: Patient{
Name: "Ion Popescu",
Email: "[email protected]",
Phone: "+40 700 123 456",
Birthdate: time.Date(1985, 5, 15, 0, 0, 0, 0, time.UTC),
Age: 40,
},
Specialist: Specialist{
Name: "Dr. Maria Ionescu",
Title: "MD, Physiotherapist",
LicenseNumber: "12345",
SignatureURL: "https://via.placeholder.com/200x80",
},
FormValues: map[string]interface{}{
"field_10": "Amsterdam",
"field_11": "Big pain",
"field_15": "2025-02-15",
},
FormFields: []FormField{
{CustomFieldID: 10, Label: "City", Value: "Amsterdam"},
{CustomFieldID: 11, Label: "Pain Level", Value: "Big pain"},
{CustomFieldID: 15, Label: "Visit Date", Value: "2025-02-15"},
},
}
// Parse and execute template
t, err := template.New("preview").
Funcs(templateFuncs()).
Parse(tmpl.TemplateHTML)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return nil, err
}
html := buf.String()
if tmpl.TemplateCSS != "" {
html = injectCSS(html, tmpl.TemplateCSS)
}
// Return HTML or PDF
if format == "html" {
return []byte(html), nil
}
// Render to PDF
pdfOptions := ParseLayoutConfig(tmpl.LayoutConfig)
return s.pdfRenderer.RenderPDF(ctx, html, pdfOptions)
}Print-Specific CSS
css
/* Page setup */
@page {
size: A4 portrait;
margin: 20mm;
}
/* Force page breaks */
.page-break {
page-break-before: always;
}
.avoid-break {
page-break-inside: avoid;
}
/* Print-only styles */
@media print {
body {
font-family: 'Helvetica', 'Arial', sans-serif;
font-size: 12pt;
line-height: 1.6;
color: #000;
}
/* Hide web-only elements */
.no-print {
display: none !important;
}
/* Ensure backgrounds are printed */
* {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Header/footer positioning */
.page-header {
position: running(header);
}
.page-footer {
position: running(footer);
}
@page {
@top-center {
content: element(header);
}
@bottom-center {
content: element(footer);
}
}
}
/* Screen-only styles */
@media screen {
body {
background: #f5f5f5;
padding: 20px;
}
.page {
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
max-width: 210mm; /* A4 width */
margin: 0 auto;
padding: 20mm;
}
}Handling Images
Base64 Embedded Images
go
// Embed images as base64 in HTML (for offline rendering)
func embedImages(html string) (string, error) {
re := regexp.MustCompile(`<img src="(https?://[^"]+)"`)
return re.ReplaceAllStringFunc(html, func(match string) string {
url := re.FindStringSubmatch(match)[1]
// Fetch image
resp, err := http.Get(url)
if err != nil {
return match // Keep original URL on error
}
defer resp.Body.Close()
// Read image data
data, err := io.ReadAll(resp.Body)
if err != nil {
return match
}
// Encode as base64
contentType := resp.Header.Get("Content-Type")
base64Data := base64.StdEncoding.EncodeToString(data)
return fmt.Sprintf(`<img src="data:%s;base64,%s"`, contentType, base64Data)
}), nil
}Error Handling
go
type RenderError struct {
Stage string // "template_load", "data_load", "merge", "render", "upload"
Message string
Cause error
}
func (e *RenderError) Error() string {
return fmt.Sprintf("PDF render failed at %s: %s", e.Stage, e.Message)
}
func (s *PDFService) GeneratePDF(ctx context.Context, templateID, formID int64) (*Document, error) {
html, err := s.renderer.MergeTemplate(ctx, templateID, formID)
if err != nil {
return nil, &RenderError{
Stage: "merge",
Message: "Failed to merge template with data",
Cause: err,
}
}
pdfBytes, err := s.pdfRenderer.RenderPDF(ctx, html, opts)
if err != nil {
return nil, &RenderError{
Stage: "render",
Message: "Failed to render HTML to PDF",
Cause: err,
}
}
// ... etc
}Performance Optimization
1. Connection Pooling
go
// Reuse Chrome instances (connection pool)
type ChromePool struct {
contexts []*chromedp.Context
mu sync.Mutex
}
func NewChromePool(size int) *ChromePool {
pool := &ChromePool{
contexts: make([]*chromedp.Context, 0, size),
}
for i := 0; i < size; i++ {
ctx, _ := chromedp.NewContext(context.Background())
pool.contexts = append(pool.contexts, ctx)
}
return pool
}
func (p *ChromePool) Acquire() *chromedp.Context {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.contexts) > 0 {
ctx := p.contexts[len(p.contexts)-1]
p.contexts = p.contexts[:len(p.contexts)-1]
return ctx
}
ctx, _ := chromedp.NewContext(context.Background())
return ctx
}
func (p *ChromePool) Release(ctx *chromedp.Context) {
p.mu.Lock()
defer p.mu.Unlock()
p.contexts = append(p.contexts, ctx)
}2. Caching
go
// Cache rendered PDFs for signed forms (immutable)
type PDFCache struct {
cache *ristretto.Cache
}
func (c *PDFCache) Get(formID, templateVersion int64) ([]byte, bool) {
key := fmt.Sprintf("pdf:%d:%d", formID, templateVersion)
val, found := c.cache.Get(key)
if !found {
return nil, false
}
return val.([]byte), true
}
func (c *PDFCache) Set(formID, templateVersion int64, pdf []byte) {
key := fmt.Sprintf("pdf:%d:%d", formID, templateVersion)
c.cache.Set(key, pdf, int64(len(pdf)))
}3. Async Rendering (Background Jobs)
go
// Queue PDF generation as background job
func (s *PDFService) QueueGeneration(ctx context.Context, templateID, formID int64) (string, error) {
jobID := uuid.New().String()
err := s.jobQueue.Enqueue(ctx, &PDFRenderJob{
JobID: jobID,
TemplateID: templateID,
FormID: formID,
})
return jobID, err
}
// Worker processes PDF generation jobs
func (s *PDFService) PDFRenderWorker(ctx context.Context) {
for {
job := s.jobQueue.Dequeue(ctx)
doc, err := s.GeneratePDF(ctx, job.TemplateID, job.FormID)
if err != nil {
s.logger.Error("PDF generation failed", "job_id", job.JobID, "error", err)
continue
}
s.logger.Info("PDF generated", "job_id", job.JobID, "document_id", doc.ID)
}
}Testing
Unit Tests
go
func TestMergeTemplate(t *testing.T) {
renderer := &TemplateRenderer{}
html, err := renderer.MergeTemplate(context.Background(), 5, 201)
require.NoError(t, err)
assert.Contains(t, html, "Ion Popescu")
assert.Contains(t, html, "Big pain")
}
func TestRenderPDF(t *testing.T) {
renderer := &PDFRenderer{}
html := `<html><body><h1>Test PDF</h1></body></html>`
pdfBytes, err := renderer.RenderPDF(context.Background(), html, DefaultA4Options())
require.NoError(t, err)
assert.Greater(t, len(pdfBytes), 1000) // PDF should be > 1KB
assert.Equal(t, []byte("%PDF"), pdfBytes[:4]) // PDF magic number
}Summary
✅ chromedp rendering - Headless Chrome for HTML → PDF ✅ Go template syntax - Dynamic content injection ✅ Print-specific CSS - @page, page breaks, backgrounds ✅ Image embedding - Base64 or S3 URLs ✅ Layout options - A4, Letter, portrait/landscape, margins ✅ Performance - Connection pooling, caching, async jobs ✅ WYSIWYG - Preview uses same renderer as final PDF
This approach ensures pixel-perfect PDFs with full control over layout and styling.