Skip to content

PDF Generation Pipeline

Overview

The PDF generation system transforms form data into professional, printable documents using a template-driven approach. PDFs are generated on-demand when requested, with caching for signed (immutable) documents.

PDF Engine: chromedp

Why chromedp (Headless Chrome)

OptionProsConsVerdict
chromedp (headless Chrome)Full CSS/HTML support, pixel-perfect rendering, handles complex layouts, tables, images, signaturesRequires Chrome binary in container (~150MB), higher memory per renderRecommended
maroto/v2 (Go-native PDF)Pure Go, no external dependency, fast, small binaryLimited layout control, no HTML/CSS, hard to maintain complex templatesGood for simple documents only
wkhtmltopdfProven HTML-to-PDF, smaller than ChromeDeprecated upstream, WebKit-based (rendering differences), CGO dependencyNot recommended
gotenberg (microservice)Decoupled, scalable, API-basedExtra infrastructure, network latency, another service to maintainExtra service to maintain - The Core API handles documents independently
UniPDFFeature-rich Go libraryCommercial license required, expensiveLicense concern

chromedp Benefits

  • HTML/CSS templates are maintainable by frontend developers (not just Go developers)
  • Supports embedded images (specialist signatures, logos, patient uploads)
  • Handles RTL text, Unicode, special characters (Romanian diacritics)
  • Tables, headers/footers, page breaks work natively
  • Same Chrome rendering as what users see in browser previews
  • Container image: use chromedp/headless-shell (~150MB) as a sidecar or baked into the main image

Container Strategy

dockerfile
# Multi-stage build
FROM chromedp/headless-shell:latest AS chrome
FROM golang:1.23-alpine AS builder
# ... build Go binary ...

FROM alpine:3.20
# Copy Chrome from first stage
COPY --from=chrome /headless-shell /headless-shell
COPY --from=builder /app/server /app/server

Binary size stays ~20MB. Chrome headless shell adds ~150MB. Total image: ~170MB (still far less than Node + node_modules).

Generation Pipeline

Flow: On-Demand PDF Generation

Client requests PDF


┌──────────────────┐
│  GET /v1/reports │
│  /{id}/pdf       │
│  ?audience=patient│
└────────┬─────────┘


┌──────────────────────────────────┐
│ 1. Load appointment_document    │
│    + form + appointment          │
│    + specialist + patient        │
│    + custom_field_values         │
│    + organization                │
└────────┬─────────────────────────┘


┌──────────────────────────────────┐
│ 2. Load pdf_template            │
│    (from form.pdf_template_id)   │
│    See: pdf-templates feature    │
└────────┬─────────────────────────┘


┌──────────────────────────────────┐
│ 3. Build DocumentData struct    │
│    - Resolve form field values  │
│    - Filter private fields      │
│      (if audience=patient)      │
│    - Pre-sign S3 URLs           │
│    - Embed images as base64     │
└────────┬─────────────────────────┘


┌──────────────────────────────────┐
│ 4. Render HTML                  │
│    html/template.Execute()      │
└────────┬─────────────────────────┘


┌──────────────────────────────────┐
│ 5. Convert HTML → PDF           │
│    chromedp: navigate to         │
│    data URI, print to PDF        │
└────────┬─────────────────────────┘


┌──────────────────────────────────┐
│ 6. Return PDF                   │
│    Content-Type: application/pdf│
│    Content-Disposition: inline  │
│                                  │
│    OR upload to S3 and return   │
│    signed URL (for caching)     │
└──────────────────────────────────┘

DocumentData Structure

Templates receive a DocumentData struct rendered via Go's html/template:

go
type DocumentData struct {
    // Organization context
    Organization OrganizationInfo

    // Appointment details
    Appointment AppointmentInfo

    // Patient profile
    Patient PatientInfo

    // Specialist who conducted the appointment
    Specialist SpecialistInfo

    // Form data — the core content
    Form FormData

    // Custom field values for the patient (profile data)
    CustomFields map[string]string

    // Document metadata
    Document DocumentMeta

    // Generation context
    GeneratedAt time.Time
    Locale      string // "ro-RO", "en-US", etc.
}

type OrganizationInfo struct {
    Name    string
    LogoURL string // Pre-signed S3 URL, embedded as base64 in HTML before rendering
    Address string
    Phone   string
    Email   string
}

type SpecialistInfo struct {
    Name         string
    Title        string // "Dr.", "Kinesitherapist", etc.
    SignatureURL string // Pre-signed S3 URL, embedded as base64
    Specialties  []string
}

type PatientInfo struct {
    Name        string
    DateOfBirth string
    // Other profile fields from custom_field_values
}

type FormData struct {
    Title  string
    Type   string // "report", "prescription", etc.
    Fields []FormFieldRendered
    Groups map[string][]FormFieldRendered // Fields organized by group
}

type FormFieldRendered struct {
    Key      string
    Label    string
    Type     string // "text", "number", "select", "file", "richtext", etc.
    Value    any    // Resolved value from forms.values JSONB
    FileURL  string // Pre-signed S3 URL if type is "file"
    Private  bool   // true = specialist-only (excluded from patient PDF)
    Group    string
    Required bool
}

type DocumentMeta struct {
    Title     string
    Type      string // "report" | "prescription"
    CreatedAt time.Time
    SignedAt  *time.Time // nil if not yet signed
}

Go Package Structure

internal/
├── document/
│   ├── handler.go              # HTTP handlers for /reports/{id}/pdf, /prescriptions/{id}/pdf
│   ├── service.go              # Document generation orchestration
│   ├── renderer.go             # HTML template rendering (Go html/template)
│   ├── pdf.go                  # chromedp HTML-to-PDF conversion
│   ├── cache.go                # S3 cache read/write for signed document PDFs
│   ├── data.go                 # DocumentData struct + builder
│   ├── funcmap.go              # Template function definitions (formatDate, safeHTML, etc.)
│   └── errors.go               # Document-specific error types

│ Note: PDF template management (CRUD, versioning, components) is in
│       the separate pdf-templates service (see ../pdf-templates/)

Key Interfaces

go
// PDFRenderer converts HTML to PDF bytes.
type PDFRenderer interface {
    RenderPDF(ctx context.Context, html string, opts PDFOptions) ([]byte, error)
}

// PDFOptions controls PDF output.
type PDFOptions struct {
    PageSize    string // "A4", "Letter"
    Orientation string // "portrait", "landscape"
    Margins     Margins
    HeaderHTML  string
    FooterHTML  string
}

// DocumentCache handles S3-based PDF caching for signed documents.
type DocumentCache interface {
    Get(ctx context.Context, key CacheKey) (io.ReadCloser, error)
    Put(ctx context.Context, key CacheKey, pdf []byte) error
    Invalidate(ctx context.Context, documentID int64) error
}

type CacheKey struct {
    OrganizationID int64
    DocumentID     int64
    Audience       string
    TemplateID     int64
}

// DocumentDataBuilder assembles all data needed for PDF rendering.
type DocumentDataBuilder interface {
    Build(ctx context.Context, documentID int64, audience string) (*DocumentData, error)
}

S3 Storage Layout

s3://{bucket}/
├── documents/
│   └── {org_id}/
│       └── {document_id}/
│           ├── patient.pdf         # Cached patient-audience PDF
│           ├── specialist.pdf      # Cached specialist-audience PDF
│           └── admin.pdf           # Cached admin-audience PDF
├── uploads/
│   └── {org_id}/
│       └── forms/
│           └── {form_id}/
│               └── field_{custom_field_id}/
│                   └── {filename}  # Form field file uploads
├── signatures/
│   └── {org_id}/
│       └── specialists/
│           └── {specialist_id}/
│               └── signature.png   # Specialist signature image
└── templates/
    └── {org_id}/
        └── logos/
            └── logo.png            # Organization logo

All URLs are accessed via pre-signed S3 URLs with 15-minute expiry. Images embedded in PDFs are fetched and base64-encoded at render time (no external URL dependencies in the final PDF).

Prescription Validation

Before generating a prescription PDF:

go
func validatePrescriptionGeneration(doc *AppointmentDocument, specialist *Specialist) error {
    if specialist.Signature == "" {
        return ErrSpecialistSignatureRequired
    }
    if doc.Form == nil {
        return ErrFormNotAttached
    }
    if doc.Form.Status != FormStatusSigned {
        return ErrFormNotSigned
    }
    return nil
}

Performance Considerations

ConcernMitigation
chromedp cold startKeep a warm browser context pool (1-3 instances)
PDF render timeTypical document: 200-500ms. Acceptable for on-demand. Monitor P95
S3 cache hit ratioSigned documents are ~80% of requests. Cache eliminates repeated renders
Image embeddingFetch + base64 encode adds 50-100ms per image. Parallel fetch all images
Memory per render~50-100MB per Chrome tab. Pool size limits concurrent renders
Template compilationCache compiled html/template objects in memory (invalidate on template update)

Concurrency Limits

go
// Pool of Chrome contexts for concurrent PDF generation.
// Prevents OOM from unbounded parallel renders.
const maxConcurrentRenders = 3

var renderSemaphore = make(chan struct{}, maxConcurrentRenders)

func (s *Service) GeneratePDF(ctx context.Context, ...) ([]byte, error) {
    renderSemaphore <- struct{}{}
    defer func() { <-renderSemaphore }()
    // ... render ...
}

Default Templates

On organization creation, seed two default templates:

  1. Default Report Template - Displays organization header, patient info, appointment date, specialist name, all non-private form fields, specialist signature, generation timestamp.
  2. Default Prescription Template - Same header, patient info, all form fields (no private filtering for prescriptions), specialist signature (required), generation timestamp.

Templates are simple, clean, and professional. Organizations can customize by editing the HTML/CSS via the admin API.

Future Considerations

Not in scope for initial release:

  • Batch PDF generation: Generate all PDFs for an appointment at once (report + prescription)
  • PDF/A compliance: Archive-quality PDFs for long-term medical record storage
  • Watermarking: "DRAFT" watermark on unsigned document previews
  • Multi-language templates: Same form data rendered in different languages
  • QR codes: Embed verification QR codes linking to the digital form
  • E-signature integration: If Romanian regulations require qualified electronic signatures (eIDAS), integrate with a qualified trust service provider