Skip to content

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 URL

Template 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)
}

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.