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)
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| chromedp (headless Chrome) | Full CSS/HTML support, pixel-perfect rendering, handles complex layouts, tables, images, signatures | Requires Chrome binary in container (~150MB), higher memory per render | Recommended |
| maroto/v2 (Go-native PDF) | Pure Go, no external dependency, fast, small binary | Limited layout control, no HTML/CSS, hard to maintain complex templates | Good for simple documents only |
| wkhtmltopdf | Proven HTML-to-PDF, smaller than Chrome | Deprecated upstream, WebKit-based (rendering differences), CGO dependency | Not recommended |
| gotenberg (microservice) | Decoupled, scalable, API-based | Extra infrastructure, network latency, another service to maintain | Extra service to maintain - The Core API handles documents independently |
| UniPDF | Feature-rich Go library | Commercial license required, expensive | License 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
# 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/serverBinary 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:
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
// 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 logoAll 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:
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
| Concern | Mitigation |
|---|---|
| chromedp cold start | Keep a warm browser context pool (1-3 instances) |
| PDF render time | Typical document: 200-500ms. Acceptable for on-demand. Monitor P95 |
| S3 cache hit ratio | Signed documents are ~80% of requests. Cache eliminates repeated renders |
| Image embedding | Fetch + 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 compilation | Cache compiled html/template objects in memory (invalidate on template update) |
Concurrency Limits
// 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:
- Default Report Template - Displays organization header, patient info, appointment date, specialist name, all non-private form fields, specialist signature, generation timestamp.
- 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