How-to library · Developer docs

How-to library

Task-shaped pages: a title that starts with a verb, the situation in a few lines, the steps, the working code in both engines, and why this way rather than another. Found from a search, done, and left.

Generate a PDF certificate from a template

Render the certificate's HTML from a template, send it to an HTML-to-PDF service with an HTTP request, save the answer with Create file under an unguessable name, and email the link.

Context

Context. A learner finishes a course, a member renews, an event attendee checks in: each should get a PDF certificate with their name on it. No job action turns HTML into a PDF, and Merge to PDF only joins files that are already PDFs, so the conversion is done by an HTML-to-PDF service, called from the job. Everything else stays on the platform: the certificate's design is a template in your repository, the file is saved in the file manager, and the person gets a link.

Steps

  1. Choose an HTML-to-PDF service that accepts HTML in a request and answers with the PDF bytes, and put its key in site data as pdf-api-key.
  2. Write the certificate as a template, templates/hooks/certificates/course.html: one A4 page, every style inline or in a <style> block, images by absolute URL.
  3. In the job that knows the certificate is due, add a Render template certificate-html from that file.
  4. Add an HTTP request convert-to-pdf: POST the rendered HTML to the service, timeout 60, retries 2.
  5. Add Create file save-certificate: source convert-to-pdf, expires in 90 days, filename /certificates/{$event->getRecordId()}-{bin2hex(openssl_random_pseudo_bytes(8))}.pdf.
  6. Optionally, Merge to PDF the new file with a standard second page, such as terms, already in the file manager.
  7. Add Send mail with a link to the file. The complete email is on Email from jobs.

Code

Template code
{* templates/hooks/certificates/course.html: here the job's record is a completion with a learner and a course *}
{$revision = $event->getRecord()->getActiveRevision()}
{$learner = $revision->getAreaRecord('learner')}
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
  @page { size: A4 landscape; margin: 0; }
  body { margin: 0; font-family: Georgia, serif; color: #222222; }
  .sheet { box-sizing: border-box; width: 297mm; height: 210mm; padding: 30mm; border: 8mm solid #1a3d6d; text-align: center; }
  h1 { font-size: 34pt; margin: 0 0 12mm; }
  .name { font-size: 28pt; margin: 8mm 0; }
</style></head>
<body><div class="sheet">
  <img src="https://{$site->getHostName()}/files/images/certificates/logo.png" alt="" style="height:18mm;">
  <h1>Certificate of completion</h1>
  <p>This certifies that</p>
  <p class="name">{$learner->getFirstName()|escape} {$learner->getLastName()|escape}</p>
  <p>completed <strong>{$revision->course}</strong> on {$event->getRecord()->getCreated()->format('j F Y')}.</p>
  <p style="font-size:9pt;color:#777777;">Certificate {$event->getRecordId()}</p>
</div></body></html>
{# templates/hooks/certificates/course.html: here the job's record is a completion with a learner and a course #}
{% set revision = event.getRecord().getActiveRevision() %}
{% set learner = revision.getAreaRecord('learner') %}
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
  @page { size: A4 landscape; margin: 0; }
  body { margin: 0; font-family: Georgia, serif; color: #222222; }
  .sheet { box-sizing: border-box; width: 297mm; height: 210mm; padding: 30mm; border: 8mm solid #1a3d6d; text-align: center; }
  h1 { font-size: 34pt; margin: 0 0 12mm; }
  .name { font-size: 28pt; margin: 8mm 0; }
</style></head>
<body><div class="sheet">
  <img src="https://{{ site.getHostName() }}/files/images/certificates/logo.png" alt="" style="height:18mm;">
  <h1>Certificate of completion</h1>
  <p>This certifies that</p>
  <p class="name">{{ learner.getFirstName() }} {{ learner.getLastName() }}</p>
  <p>completed <strong>{{ revision.course }}</strong> on {{ event.getRecord().getCreated()|date('j F Y') }}.</p>
  <p style="font-size:9pt;color:#777777;">Certificate {{ event.getRecordId() }}</p>
</div></body></html>

A Smarty template can hold a CSS block like this one because each opening brace is followed by a space or a line break, which Smarty leaves alone. The request body, whatever the service's field is called:

Template code
{$request = ['html' => $runData->get('certificate-html')->content, 'format' => 'A4', 'landscape' => true]}
{$request|json_encode}
{{ {'html': runData.get('certificate-html').content, 'format': 'A4', 'landscape': true}|json_encode }}

with headers Authorization: Bearer {$siteData->get('pdf-api-key')} and Accept: application/pdf. Create file takes the response body as the file's content, binary included.

Why this and not that

A conversion service, not Merge to PDF. Merge to PDF concatenates existing PDFs; given HTML it produces a file that is not a usable PDF, without failing. A folder and a random part in the filename, for two reasons: a file saved without a folder is kept in the batch's own folder and deleted with the batch when its persistence discards the run, and anything under /files/ is served to whoever has the URL, so the name must not be guessable from an id. A link rather than an attachment, because Send mail cannot attach. And test the design with the service's own preview first: print CSS support differs between services, and what a browser shows is not what the PDF will.

Go deeper

Was this page helpful?

Your answer goes to the docs team.