- What is TestInvite?
- Build Your First Test
- Run Your First Assessment
- Taking the Assessment
- Viewing the Results
- Question Bank Overview
- Common Question Features
- Scoring
- Question Types
- Question Authoring
- Browsing Questions
- Content Blocks
- Media Library
- Metadata Rules & Schema
- Tests Overview
- My Tests
- Creating a Test
- The Test Editor
- Test Settings
- Sections & Pages
- Adding Questions
- Page Builders
- Test Profile
- Reporting
- Test Papers
- Analytics
- Publishing a Test
- Test Library
- Marketplace
- Tasks Overview
- Creating a Task
- Task Dashboard
- Steps
- Task Settings
- Candidates
- Test Sessions
- Sent Mails
- Proctoring
- Analytics
- Analytics Overview
- Test Analytics
- Question Analytics
- Per-Question Analytics
- Results Explorer
- Segment Analysis
Writing the Template
Templates use Nunjucks with HTML escaping on by default — use | safe only for HTML you wrote — and must carry all their own CSS and fonts, because the report is isolated from the app's styles.
The template turns the function's output into HTML. Its variables are the keys of the object your function returns (or of the Report Input, if there is no function).
Engine and type
- Template engine — choose Nunjucks, a Jinja2-style engine with conditions, loops, filters, expressions and macros. Mustache is also available but far more limited.
- Type — choose Plain HTML for reports. Plain text is meant for things like email subjects; Rich HTML is a visual editor that is not suited to designed reports.
Escaping
Output is HTML-escaped by default: {{ value }} prints <b> as visible text. This protects the report from markup inside candidate answers. To output HTML that you wrote yourself, add the safe filter:
{{ candidate.name }} {# escaped — right for any user-entered text #}
{{ bandDescriptionHtml | safe }} {# raw HTML you built in your function #}Never use | safe on text a candidate typed (form fields, written answers). Macro output is not escaped twice.
Syntax primer
{{ overall.percent }}% {# print a value #}
{{ value | round(1) }} {{ name | upper }} {# filters #}
{{ labels | join(", ") }} {{ items | length }}
{{ phone | default("—") }} {# fallback for missing values #}
{% if verdict and verdict.passed %}Passed{% elif verdict %}Not passed{% endif %}
{% for d in dimensions %}
<tr><td>{{ d.name }}</td><td>{{ d.percent }}%</td></tr>
{% else %}
<tr><td>No dimensions</td></tr>
{% endfor %}
{% for name, dim in result.dimensions %}{{ name }}: {{ dim.stats.point }}{% endfor %} {# loop over a map #}
{% set accent = "#1b7f4b" %} {# local variable #}
{{ "#1b7f4b" if verdict.passed else "#c53030" }} {# inline condition #}
{# a comment — not rendered #}A missing value prints as an empty string, even deep in a path: {{ a.b.c }} prints nothing when a doesn't exist, rather than failing. That keeps reports from breaking, but it also hides typos — if something is blank, check the variable name against Output: Data.
Rules for a template that renders correctly everywhere
- Bring your own styles. Put all CSS in a
<style>block in the template. The report is isolated from the app, so it inherits nothing — not even a font. Load web fonts with@importfrom a public URL such as Google Fonts. - Scope your CSS under a wrapper class (
.rpt h1 { … }), so it stays contained when the report is embedded above or below the Standard Template. - Use absolute
https://image URLs (or data URIs). - Don't rely on scripts. Treat the report as static HTML; scripts are not part of the contract and don't run in PDFs.
- Draw charts with HTML/CSS or inline SVG — bars whose widths come from your function, not a JavaScript chart library.
- For A4 output, split the report into
div.ti-pageblocks — see Print & PDF Layout.
Complete example
The template for the complete example on Writing the Function: one A4 page with the overall score, band and verdict chips, a dimension table with score bars and percentiles, and section scores.
<style>
.rpt { font-family: Arial, Helvetica, sans-serif; color: #1a202c; }
.rpt .ti-page { width: 210mm; min-height: 297mm; padding: 18mm 16mm; box-sizing: border-box; background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.18); margin: 0 auto 24px; }
.rpt h1 { font-size: 22px; margin: 0 0 4px; }
.rpt .muted { color: #718096; font-size: 12px; }
.rpt .hero { display: flex; align-items: center; gap: 24px; margin: 24px 0; padding: 16px; border: 1px solid #e2e8f0; border-radius: 8px; }
.rpt .big { font-size: 40px; font-weight: 700; }
.rpt .chip { display: inline-block; padding: 2px 10px; border-radius: 12px; color: #fff; font-size: 12px; font-weight: 700; }
.rpt table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rpt th, .rpt td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #edf2f7; }
.rpt .bar { height: 8px; background: #edf2f7; border-radius: 4px; overflow: hidden; }
.rpt .bar > span { display: block; height: 100%; }
@media print {
.rpt .ti-page { box-shadow: none; margin: 0; min-height: unset; }
.rpt .ti-page:not(:last-child) { break-after: page; page-break-after: always; }
}
</style>
<div class="rpt">
{% if not ready %}
<div class="ti-page"><p>This report will be available once the session is scored.</p></div>
{% else %}
<div class="ti-page">
<h1>{{ testTitle }}</h1>
<div class="muted">{{ candidate.name }} · {{ candidate.email }} · Completed {{ completedAt }}</div>
<div class="hero">
<div class="big">{{ overall.percent }}%</div>
<div>
<div>{{ overall.point }} / {{ overall.totalPoint }} points</div>
<span class="chip" style="background: {{ overall.band.color }}">{{ overall.band.label }}</span>
{% if verdict %}
<span class="chip" style="background: {{ '#1b7f4b' if verdict.passed else '#c53030' }}">{{ verdict.label }}</span>
{% endif %}
</div>
</div>
<h3>Competencies</h3>
<table>
<tr><th>Dimension</th><th style="width:40%">Score</th><th>%</th><th>Percentile</th></tr>
{% for d in dimensions %}
<tr>
<td>{{ d.name }}</td>
<td><div class="bar"><span style="width: {{ d.percent }}%; background: {{ d.band.color }}"></span></div></td>
<td>{{ d.percent }}%</td>
<td>{{ d.percentile if d.percentile !== null else "—" }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">No dimensions in this test.</td></tr>
{% endfor %}
</table>
<h3>Sections</h3>
<table>
{% for s in sections %}
<tr><td>{{ s.title }}</td><td>{{ s.percent }}%</td></tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>