Skip to content

PDF Template Components - Reusable Blocks

NOT BUILT — reconciled 2026-08-02

No pdf_templates, pdf_template_versions or pdf_template_components table exists, no editor, no renderer, no routes, and zero permission rows. Ships in migration 000047 with F6 Documents — last in the F1 → F2.1 → F3 → F4 → F5 → F6 chain.

The block-based design on these pages is the winning designdata-model.md records that it was evaluated against a per-org HTML/CSS document_templates design and won; that alternative was deleted. A migration document in the legacy repo still recommends the losing design; architecture docs beat foreign migration docs.

Corrections that apply throughout:

  • chromedp is the leading candidate, not the decision. The engine choice (chromedp / Gotenberg / Go-native) and sync-vs-queued generation are open, and they block the SOUP row and the ECS task shape. Profile CPU on Fargate before committing — the media service was already burned once by a tool reading host core count instead of the cgroup limit. See features.md → F6.2.
  • Whatever wins is a pdf.Renderer capability in internal/core/pdf/, registered via capabilities.WrapInternal, with a SOUP row in the same PR.
  • UUID PKs, not integers. No users table — actors are *_principal_id, and the patient subject is patient_profile_id.
  • Authorization is per-org permission codes, not roles; per-org route groups mount RequireURLOrgMatchesScope("id") (P47).
  • The template variable namespace is a server-side allow-list derived from the classification registry — not "whatever is on the object". Egress goes through classification.AllowedFor / classification.Filter (P39). The legacy system interpolated a plaintext password field into rendered consent text.
  • Patient demographics resolve through the renderer's own scoped query, not left to template authors — it reads the portable profile only through a patients row at the printing org.
  • CNP is opt-in per template, default off (settled 2026-08-02) — pii_regulated, encrypted BYTEA on patient_profiles, with its own explicit egress target.
  • PDFs are self-contained — signatures base64-embedded, images fetched server-side and inlined, no external URLs.
  • Cross-tenant template sharing is P49 platform-tier + clone. Never move — moving re-parents historical clinical documents across a tenant boundary.

Component library for building professional PDF templates with reusable blocks.


Overview

PDF template components are reusable HTML/CSS snippets that can be inserted into multiple templates. They ensure:

  • Consistency - Same letterhead across all documents
  • Maintainability - Update once, applies to all templates
  • Organization branding - Each org has custom components
  • Time savings - Drag-drop instead of writing HTML

Component Categories

CategoryPurposeExamples
letterheadOrganization headerLogo + name + address
footerPage footerOrganization info, page numbers, legal notice
signatureSpecialist signatureSignature image, name, title, license
headerDocument headerDocument title, metadata, date
tableReusable table layoutsService line items, medication tables
sectionContent sectionsPatient info, findings, recommendations

Default Components

1. Standard Letterhead

Category: letterhead

HTML:

html
<div class="letterhead">
  <div class="letterhead-logo">
    {{if .Organization.LogoURL}}
      <img src="{{.Organization.LogoURL}}" alt="{{.Organization.Name}}" class="logo" />
    {{end}}
  </div>
  <div class="letterhead-info">
    <h1 class="org-name">{{.Organization.Name}}</h1>
    <p class="org-contact">
      {{.Organization.Address}}<br>
      Tel: {{.Organization.Phone}} | Email: {{.Organization.Email}}
      {{if .Organization.Website}}
        <br>{{.Organization.Website}}
      {{end}}
    </p>
  </div>
</div>

CSS:

css
.letterhead {
  text-align: center;
  border-bottom: 2px solid #003366;
  padding-bottom: 10mm;
  margin-bottom: 10mm;
}

.letterhead-logo {
  margin-bottom: 5mm;
}

.letterhead .logo {
  max-width: 150px;
  height: auto;
}

.letterhead .org-name {
  font-size: 18pt;
  color: #003366;
  margin: 0 0 5px 0;
  font-weight: bold;
}

.letterhead .org-contact {
  font-size: 10pt;
  color: #666;
  margin: 0;
  line-height: 1.4;
}

Variables Used:

  • Organization.LogoURL
  • Organization.Name
  • Organization.Address
  • Organization.Phone
  • Organization.Email
  • Organization.Website

2. Specialist Signature Block

Category: signature

HTML:

html
<div class="signature-block">
  <div class="signature-line">
    {{if .Specialist.SignatureURL}}
      <img src="{{.Specialist.SignatureURL}}" alt="Signature" class="signature-image" />
    {{else}}
      <div class="signature-placeholder">
        _______________________________
      </div>
    {{end}}
  </div>
  <div class="signature-info">
    <p class="specialist-name"><strong>{{.Specialist.Name}}</strong></p>
    <p class="specialist-title">{{.Specialist.Title}}</p>
    {{if .Specialist.LicenseNumber}}
      <p class="specialist-license">License No: {{.Specialist.LicenseNumber}}</p>
    {{end}}
  </div>
</div>

CSS:

css
.signature-block {
  margin-top: 30mm;
  text-align: right;
  page-break-inside: avoid;
}

.signature-line {
  margin-bottom: 5mm;
}

.signature-image {
  max-width: 200px;
  height: auto;
}

.signature-placeholder {
  font-size: 12pt;
  color: #999;
}

.signature-info {
  font-size: 11pt;
  line-height: 1.4;
}

.signature-info p {
  margin: 2px 0;
}

.specialist-name {
  font-size: 12pt;
}

.specialist-title {
  color: #666;
  font-style: italic;
}

.specialist-license {
  font-size: 10pt;
  color: #666;
}

Variables Used:

  • Specialist.SignatureURL
  • Specialist.Name
  • Specialist.Title
  • Specialist.LicenseNumber

Category: footer

HTML:

html
<div class="page-footer">
  <div class="footer-content">
    <p class="footer-text">
      {{.Organization.Name}} | Confidential Medical Document
      {{if .GeneratedAt}}
        | Generated: {{.GeneratedAt.Format "2006-01-02 15:04"}}
      {{end}}
    </p>
    <p class="footer-legal">
      This document contains confidential medical information. Unauthorized disclosure is prohibited.
    </p>
  </div>
</div>

CSS:

css
.page-footer {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  text-align: center;
  font-size: 9pt;
  color: #666;
  border-top: 1px solid #ccc;
  padding-top: 3mm;
  background: white;
}

.footer-content {
  max-width: 170mm;
  margin: 0 auto;
}

.footer-text {
  margin: 0 0 2mm 0;
  font-size: 9pt;
}

.footer-legal {
  margin: 0;
  font-size: 8pt;
  color: #999;
  font-style: italic;
}

Variables Used:

  • Organization.Name
  • GeneratedAt

4. Patient Information Section

Category: section

HTML:

html
<div class="patient-info-section">
  <h3 class="section-title">Patient Information</h3>
  <div class="info-grid">
    <div class="info-row">
      <span class="info-label">Name:</span>
      <span class="info-value">{{.Patient.Name}}</span>
    </div>
    <div class="info-row">
      <span class="info-label">Date of Birth:</span>
      <span class="info-value">{{.Patient.Birthdate.Format "January 2, 2006"}}</span>
    </div>
    <div class="info-row">
      <span class="info-label">Age:</span>
      <span class="info-value">{{.Patient.Age}} years</span>
    </div>
    {{if .Patient.Phone}}
    <div class="info-row">
      <span class="info-label">Phone:</span>
      <span class="info-value">{{.Patient.Phone}}</span>
    </div>
    {{end}}
    {{if .Patient.Email}}
    <div class="info-row">
      <span class="info-label">Email:</span>
      <span class="info-value">{{.Patient.Email}}</span>
    </div>
    {{end}}
  </div>
</div>

CSS:

css
.patient-info-section {
  margin-bottom: 10mm;
  page-break-inside: avoid;
}

.section-title {
  font-size: 14pt;
  color: #003366;
  border-bottom: 1px solid #003366;
  padding-bottom: 2mm;
  margin-bottom: 5mm;
}

.info-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 3mm;
}

.info-row {
  display: flex;
}

.info-label {
  font-weight: bold;
  min-width: 120px;
  flex-shrink: 0;
}

.info-value {
  flex: 1;
}

Variables Used:

  • Patient.Name
  • Patient.Birthdate
  • Patient.Age
  • Patient.Phone
  • Patient.Email

5. Form Fields List

Category: section

HTML:

html
<div class="form-fields-section">
  <h3 class="section-title">Consultation Details</h3>
  <div class="fields-list">
    {{range .FormFields}}
      {{if not .IsPrivate}}
        <div class="field-row">
          <span class="field-label">{{.Label}}:</span>
          <span class="field-value">{{.Value}}</span>
        </div>
      {{end}}
    {{end}}
  </div>
</div>

CSS:

css
.form-fields-section {
  margin-bottom: 10mm;
}

.fields-list {
  background: #f9f9f9;
  padding: 5mm;
  border-radius: 2mm;
}

.field-row {
  display: flex;
  padding: 2mm 0;
  border-bottom: 1px solid #eee;
}

.field-row:last-child {
  border-bottom: none;
}

.field-label {
  font-weight: bold;
  min-width: 150px;
  flex-shrink: 0;
  color: #003366;
}

.field-value {
  flex: 1;
}

Variables Used:

  • FormFields (array)
    • FormFields[].Label
    • FormFields[].Value
    • FormFields[].IsPrivate

6. Appointment Details Header

Category: header

HTML:

html
<div class="appointment-header">
  <div class="header-left">
    <h2 class="document-title">Consultation Report</h2>
    <p class="document-subtitle">{{.Appointment.ServiceName}}</p>
  </div>
  <div class="header-right">
    <p class="appointment-date">
      <strong>Date:</strong> {{.Appointment.StartedAt.Format "January 2, 2006"}}
    </p>
    <p class="appointment-time">
      <strong>Time:</strong> {{.Appointment.StartedAt.Format "15:04"}} - {{.Appointment.EndedAt.Format "15:04"}}
    </p>
  </div>
</div>

CSS:

css
.appointment-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 10mm;
  padding-bottom: 5mm;
  border-bottom: 2px solid #003366;
}

.header-left {
  flex: 1;
}

.document-title {
  font-size: 20pt;
  color: #003366;
  margin: 0 0 3mm 0;
}

.document-subtitle {
  font-size: 12pt;
  color: #666;
  margin: 0;
}

.header-right {
  text-align: right;
  font-size: 11pt;
}

.header-right p {
  margin: 2mm 0;
}

Variables Used:

  • Appointment.ServiceName
  • Appointment.StartedAt
  • Appointment.EndedAt

7. Medication Table

Category: table

HTML:

html
<div class="medication-table">
  <h3 class="section-title">Prescribed Medications</h3>
  <table class="data-table">
    <thead>
      <tr>
        <th>Medication</th>
        <th>Dosage</th>
        <th>Frequency</th>
        <th>Duration</th>
      </tr>
    </thead>
    <tbody>
      {{range .Medications}}
      <tr>
        <td>{{.Name}}</td>
        <td>{{.Dosage}}</td>
        <td>{{.Frequency}}</td>
        <td>{{.Duration}}</td>
      </tr>
      {{end}}
    </tbody>
  </table>
</div>

CSS:

css
.medication-table {
  margin-bottom: 10mm;
}

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 11pt;
}

.data-table thead {
  background: #003366;
  color: white;
}

.data-table th {
  padding: 3mm;
  text-align: left;
  font-weight: bold;
}

.data-table td {
  padding: 3mm;
  border-bottom: 1px solid #ddd;
}

.data-table tbody tr:nth-child(even) {
  background: #f9f9f9;
}

.data-table tbody tr:hover {
  background: #f0f0f0;
}

Variables Used:

  • Medications (array)
    • Medications[].Name
    • Medications[].Dosage
    • Medications[].Frequency
    • Medications[].Duration

Creating Custom Components

  1. Navigate to Settings → PDF Templates → Components
  2. Click "Create Component"
  3. Fill in:
    • Name: "Custom Letterhead"
    • Category: letterhead
    • Description: "Letterhead with custom branding"
  4. Write HTML in editor (Monaco editor with syntax highlighting)
  5. Write CSS in editor
  6. Click "Preview" to see rendered component
  7. Click "Save"

Via API

bash
curl -X POST https://api.example.com/v1/pdf-template-components \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Custom Letterhead",
    "description": "Letterhead with custom branding",
    "category": "letterhead",
    "component_html": "<div class=\"letterhead\">...</div>",
    "component_css": ".letterhead { text-align: center; }",
    "variables_used": ["Organization.LogoURL", "Organization.Name"]
  }'

Using Components in Templates

Via Block Editor

  1. Drag "Letterhead" block from palette
  2. In properties panel:
    • Select "Standard Letterhead" from dropdown
    • Or click "Customize" to edit inline
  3. Component HTML/CSS is inserted into template

Manually in HTML

html
<!DOCTYPE html>
<html>
<head>
  <style>
    {{template "letterhead_css"}}
  </style>
</head>
<body>
  {{template "letterhead"}}

  <h2>Consultation Report</h2>

  <!-- Your content here -->

  {{template "signature"}}
  {{template "footer"}}
</body>
</html>

Component Versioning

Components are not versioned (unlike templates). Changes to components affect all templates that use them immediately.

Best practices:

  1. Don't break existing templates - If making breaking changes, create a new component
  2. Test before updating - Preview all templates using the component
  3. Communicate changes - Notify admins when updating shared components

Component Library Management

Searching Components

typescript
const SearchComponents: React.FC = () => {
  const [components, setComponents] = useState<PDFTemplateComponent[]>([]);
  const [searchTerm, setSearchTerm] = useState('');
  const [categoryFilter, setCategoryFilter] = useState<string | null>(null);

  const filteredComponents = useMemo(() => {
    return components.filter(c => {
      const matchesSearch = c.name.toLowerCase().includes(searchTerm.toLowerCase());
      const matchesCategory = !categoryFilter || c.category === categoryFilter;
      return matchesSearch && matchesCategory;
    });
  }, [components, searchTerm, categoryFilter]);

  return (
    <div className="component-library">
      <SearchBar value={searchTerm} onChange={setSearchTerm} />
      <CategoryFilter value={categoryFilter} onChange={setCategoryFilter} />

      <div className="component-grid">
        {filteredComponents.map(component => (
          <ComponentCard
            key={component.id}
            component={component}
            onInsert={() => insertComponent(component)}
          />
        ))}
      </div>
    </div>
  );
};

Component Preview

typescript
const ComponentPreview: React.FC<{ component: PDFTemplateComponent }> = ({ component }) => {
  const [previewHTML, setPreviewHTML] = useState('');

  useEffect(() => {
    // Merge component with sample data
    const sampleData = {
      Organization: {
        Name: 'Sample Clinic',
        LogoURL: 'https://via.placeholder.com/150',
        Address: '123 Main St',
        Phone: '+1 234 567 8900',
      },
    };

    // Render component HTML with sample data
    const html = renderTemplate(component.component_html, sampleData);

    // Wrap in HTML document with CSS
    const fullHTML = `
      <!DOCTYPE html>
      <html>
      <head>
        <style>${component.component_css}</style>
      </head>
      <body>
        ${html}
      </body>
      </html>
    `;

    setPreviewHTML(fullHTML);
  }, [component]);

  return (
    <div className="component-preview">
      <iframe srcDoc={previewHTML} sandbox="allow-same-origin" />
    </div>
  );
};

Advanced: Nested Components

Components can reference other components:

html
<!-- "Full Report Header" component -->
<div class="report-header">
  {{template "letterhead"}}

  <div class="appointment-details">
    {{template "appointment_header"}}
  </div>
</div>

Implementation:

  1. Store component dependencies in components_used JSONB
  2. When rendering, resolve nested components recursively
  3. Prevent circular dependencies (max depth: 3)

Component Analytics

Track component usage to understand which are most popular:

sql
-- Find most-used components
SELECT
  c.id,
  c.name,
  c.category,
  COUNT(DISTINCT t.id) AS templates_using
FROM pdf_template_components c
LEFT JOIN pdf_templates t ON t.components_used @> jsonb_build_array(c.name)
GROUP BY c.id, c.name, c.category
ORDER BY templates_using DESC;

Component Marketplace (Future)

Allow sharing components across organizations:

sql
ALTER TABLE pdf_template_components
ADD COLUMN is_public BOOLEAN DEFAULT FALSE,
ADD COLUMN downloads INT DEFAULT 0;

-- Public components (shared across orgs)
SELECT * FROM pdf_template_components
WHERE is_public = true
ORDER BY downloads DESC;

Summary

7 default components - Letterhead, footer, signature, patient info, form fields, appointment header, medication table ✅ Organization-scoped - Each org can customize components ✅ Reusable - Insert into multiple templates ✅ Category-based - Organized by purpose (header, footer, signature, etc.) ✅ Live preview - See component before inserting ✅ Template variables - Access all template data (Organization, Patient, Specialist, etc.) ✅ No versioning - Changes affect all templates immediately

Components are the building blocks of professional PDF templates, ensuring consistency and saving time.