Certificate templates
Uploading and authoring custom LaTeX templates for calibration certificates.
An admin can replace the built-in certificate design with a custom .tex template — for
localization, a different layout, or matching an existing accredited-lab certificate format —
without touching any code. Templates are managed from Admin Panel → Certificate Templates.
How rendering works
A template is a .tex file using Jinja placeholders with
LaTeX-safe delimiters (Jinja's default {{ }}/{% %} collide with LaTeX's own {} group
syntax):
| Jinja construct | LaTeX-safe spelling |
|---|---|
| Variable | \VAR{asset_name} |
Block (if/for) | \BLOCK{if asset_notes} ... \BLOCK{endif} |
| Comment | \#{ a comment } |
Every free-text placeholder must go through the latex filter — \VAR{asset_name\|latex} — which
escapes characters LaTeX treats specially (\ { } $ & # ^ _ % ~). Numeric/pre-formatted fields
are safe either way; image paths (see below) must not be filtered, since they're plain
filenames, not text to render. The same applies to any placeholder documented below as "raw
LaTeX math" (coefficient_rows[].name, function_formula) — they contain literal $...$ math
syntax and must be used unfiltered, or the escaping would break the math mode.
The rendered .tex is compiled to PDF by Tectonic, a
small self-contained LaTeX engine (not a full TeX Live install) — see
Deployment for the Docker image details. Tectonic runs on
XeTeX, which is Unicode-native; the built-in template uses fontspec with DejaVu Sans (bundled
with the app image) so accented names, degree signs, ±, etc. all render correctly without any
special escaping beyond the latex filter.
Uploading a template
From Admin Panel → Certificate Templates, choose a scope (a specific organization, or
Global for the fallback used by any organization without its own default — global templates
require a superadmin, since they affect every organization), upload the .tex file, give it a
name, and optionally mark it as that scope's default.
Every upload is validated with a dry-run compile (dummy data) before being saved — a template with a LaTeX syntax error or a typo'd placeholder is rejected immediately, with the compiler's error message, rather than only failing the next time a real certificate is generated.
Replacing a template's content isn't an in-place edit — upload it again as a new template and promote it to default. The old version stays around (soft-deleted, not default) so past certificates that reference it conceptually remain traceable to what generated them.
Previewing a template
Every template row (and the app's built-in default) shows a small thumbnail of its first page, rendered client-side from a freshly randomized 10-row sample calibration dataset (a synthetic linear fit with realistic noise) generated fresh on every load — a close approximation of what a real certificate looks like without needing a real calibration on hand. Click the thumbnail to open a full-size, scrollable preview of every page, with a Download button to save the rendered sample PDF.
Placeholder reference
Identity / header
| Placeholder | Type | Notes |
|---|---|---|
certificate_number | text | e.g. OG-CAL-OG-00001-v3 |
org_name | text | Falls back to "Open Gauge" if unresolved |
org_logo_path | image path, optional | \BLOCK{if org_logo_path}\includegraphics{\VAR{org_logo_path}}\BLOCK{endif} |
performer_name | text | |
performer_signature_path | image path, optional | The performing user's signature, if set |
calibration_date, due_date | text | Pre-formatted date strings |
version | integer | Also usable as a document "revision number" |
calibration_type | text | |
chapter | text | The channel's physical quantity (e.g. "Pressure"), or the asset type if there's no channel — useful for document-control-style headers grouping certificates by measurement type |
calibration_id | text | Short identifier (first 8 chars of the calibration's UUID, uppercased) distinct from certificate_number |
generated_date | text | Today's date (when the certificate is being generated), distinct from calibration_date |
qr_path | image path, optional — QR code linking to the asset |
Asset / channel
| Placeholder | Type |
|---|---|
asset_id, asset_name, asset_manufacturer, asset_model, asset_serial, asset_part_number, asset_type_label | text |
asset_notes | text, optional |
channel | object, optional — channel.id, .quantity, .unit, .min, .max, .accuracy, .range (pre-formatted "min–max unit"), .signal (pre-formatted electrical output range, or "—"), .precision (sensor's stated measurement uncertainty, or "—"), .resolution (or "—") |
Traceability
| Placeholder | Type |
|---|---|
external_lab_name, external_lab_certificate_number | text, optional |
procedure | object, optional — .id, .name, .version, .standard_ref |
reference_asset | object, optional — .id, .name, .manufacturer, .model, .serial |
temperature, humidity, pressure | text, optional (°C / %RH / Pa) |
calibration_location | text, optional — name of the location the calibration was performed at, if assigned |
Results
| Placeholder | Type |
|---|---|
coefficient_rows | list of {coefficient, name, term, value} — name is a human label with a math-mode subscript, e.g. "Offset ($a_{0}$)"; since it's raw LaTeX math, reference it as \VAR{row.name} without the latex filter (escaping would mangle the $/_/{}) |
function_formula | text, optional — the generic (non-numeric) calibration function in LaTeX math syntax, e.g. "f(x) = a_0 + a_1 x"; also raw LaTeX math — use \VAR{function_formula} unfiltered, typically wrapped in \[...\] |
equation | text, optional — the same function with actual fitted values substituted, e.g. "y = -0.047 + 1.0004*x" |
stat_rows | list of {label, value} (R², RMSE, uncertainty, etc.) |
results_summary | list of {label, value} — compact 3-row Max Error / % Full-Scale Error / Expanded Uncertainty table |
error_summary | object, optional — .abs_error, .fs_error, .uncertainty, each pre-formatted and signed with ± (e.g. "±0.12 %RH") — a 3-column single-row alternative to results_summary |
uncertainty_budget_rows | list of {source, distribution, standard_uncertainty, dof} |
effective_dof_note | text, optional |
uncertainty_statement | text, optional — full GUM §7.2.4-style sentence |
conformity | object, optional — .result_label, .specification, .decision_rule_label, .expanded_uncertainty_line |
conformity_derivation | text, optional — narrates max error + expanded uncertainty against the specification and states the pass/fail conclusion (e.g. "...totals 0.28 %RH, against a specification of ±0.8 (absolute) — the calibration CONFORMS..."); None unless both a conformity assessment and error/uncertainty values exist |
valid_range | text, optional |
notes | text, optional |
Dataset
| Placeholder | Type |
|---|---|
dataset_rows | list of {index, measured, reference, fit, residual_abs, residual_pct} |
measured_unit, reference_unit | text |
chart_path | image path, optional — scatter + fit-curve chart |
lab_footer | text, optional — printed in the page footer |
Recipes: common patterns
Every pattern below is copy-pasteable — each is lifted directly from default.tex.jinja or
example_tables.tex (see Example templates in the repo), so
it's guaranteed to compile.
Plain text field, escaped
The overwhelming majority of placeholders are plain text and must go through the latex filter:
\textbf{Asset ID:} \VAR{asset_id|latex}
\textbf{Manufacturer:} \VAR{asset_manufacturer|latex}Forgetting |latex on free text is the most common template bug — it compiles fine until a real
asset name contains &, %, _, or #, at which point LaTeX either errors or silently mangles
the output. Numeric/pre-formatted fields (version, calibration_id, table values already
formatted by the backend) don't need it, but adding it anyway is harmless — the filter only
touches the handful of characters LaTeX treats specially.
Optional field — text
Most fields can be None/empty (e.g. an asset with no notes, a calibration with no external lab).
Guard them with \BLOCK{if ...} so the line disappears instead of printing "None" or a stray
label with nothing after it:
\BLOCK{if asset_notes}
\small\textbf{\color{oggray}Notes:} \VAR{asset_notes|latex}\normalsize
\BLOCK{endif}\BLOCK{if temperature or humidity or pressure}
Environmental Conditions:\ %
\BLOCK{if temperature}Temperature: \VAR{temperature|latex}~\textdegree C\BLOCK{endif}
\BLOCK{if humidity}\ \textbullet\ Humidity: \VAR{humidity|latex}~\%RH\BLOCK{endif}
\BLOCK{if pressure}\ \textbullet\ Pressure: \VAR{pressure|latex}~Pa\BLOCK{endif}
\BLOCK{endif}Optional field — image
Image placeholders (org_logo_path, performer_signature_path, qr_path, chart_path) are
None whenever that asset doesn't exist (no logo uploaded, no signature on file, chart couldn't
be rendered) — always guard \includegraphics the same way, and never pass an image path through
|latex (it's a plain filename, not text):
\BLOCK{if performer_signature_path}
\includegraphics[width=40mm]{\VAR{performer_signature_path}}\\[-1mm]
\rule{40mm}{0.4pt}\\
\footnotesize\VAR{performer_name|latex}
\BLOCK{endif}Optional field — nested object
channel, procedure, reference_asset, conformity are None as a whole object when that
data doesn't apply to the calibration — guard the object, then access its attributes freely
inside the block without re-checking each one:
\BLOCK{if channel}
\textbf{Channel} & \textbf{Quantity} & \textbf{Unit} & \textbf{Min} & \textbf{Max} & \textbf{Accuracy}\\
\VAR{channel.id|latex} & \VAR{channel.quantity|latex} & \VAR{channel.unit|latex} & \VAR{channel.min|latex} & \VAR{channel.max|latex} & \VAR{channel.accuracy|latex}\\
\BLOCK{endif}\BLOCK{if procedure}
\textbf{Procedure ID} & \VAR{procedure.id|latex} & \textbf{Procedure Name} & \VAR{procedure.name|latex}\\
\textbf{Procedure Version} & \VAR{procedure.version|latex} & \textbf{Standard Ref.} & \VAR{procedure.standard_ref|latex}\\
\BLOCK{endif}Looping a list of rows
coefficient_rows, stat_rows, results_summary, uncertainty_budget_rows, and dataset_rows
are all lists of small objects — loop with \BLOCK{for ... in ...}\BLOCK{endfor}, one table row
per iteration:
\begin{tabular}{@{}p{24mm}@{}p{20mm}@{}p{56mm}@{}}
\toprule
\textbf{Coefficient} & \textbf{Term} & \textbf{Value}\\
\midrule
\BLOCK{for row in coefficient_rows}
\VAR{row.coefficient|latex} & \VAR{row.term|latex} & \VAR{row.value|latex}\\
\BLOCK{endfor}
\bottomrule
\end{tabular}The same loop works for stat_rows (a flatter {label, value} shape) and dataset_rows (more
columns) — only the column list inside the loop body changes:
\BLOCK{for row in stat_rows}
\VAR{row.label|latex} & \VAR{row.value|latex}\\
\BLOCK{endfor}\BLOCK{for row in dataset_rows}
\VAR{row.index|latex} & \VAR{row.measured|latex} & \VAR{row.reference|latex} & \VAR{row.fit|latex} & \VAR{row.residual_abs|latex} & \VAR{row.residual_pct|latex}\\
\BLOCK{endfor}Combine a list-guard with the loop when the whole table should disappear if there's no data at
all (rather than rendering an empty tabular with just a header row):
\BLOCK{if uncertainty_budget_rows}
\begin{tabular}{@{}p{48mm}@{}p{28mm}@{}p{45mm}@{}p{20mm}@{}}
\toprule
\textbf{Source} & \textbf{Distribution} & \textbf{Std. Uncertainty (u)} & \textbf{dof}\\
\midrule
\BLOCK{for row in uncertainty_budget_rows}
\VAR{row.source|latex} & \VAR{row.distribution|latex} & \VAR{row.standard_uncertainty|latex} & \VAR{row.dof|latex}\\
\BLOCK{endfor}
\bottomrule
\end{tabular}
\BLOCK{endif}Raw LaTeX math — do NOT filter
coefficient_rows[].name and function_formula are the two exceptions to "always use |latex"
— they contain literal LaTeX math syntax ($a_{0}$, f(x) = a_0 + a_1 x) generated by the
backend specifically to be dropped into math mode as-is. Filtering them would escape the $,
_, and {} characters that make them render as math in the first place:
% Wrong — the escape filter mangles the math syntax:
\VAR{function_formula|latex}
% Right:
\[ \VAR{function_formula} \]\BLOCK{for row in coefficient_rows}
\VAR{row.coefficient|latex} & \VAR{row.name} & \VAR{row.value|latex}\\
\BLOCK{endfor}Note row.name (unfiltered, raw math) sitting next to row.coefficient|latex and
row.value|latex (filtered, plain text) in the same row — only the field documented as "raw
LaTeX math" in the placeholder reference skips the filter.
A minimal working template
Everything above assembled into the smallest template that still produces a usable certificate — strip this down further or use it as a starting skeleton:
\documentclass[11pt]{article}
\usepackage[a4paper,margin=20mm]{geometry}
\usepackage{fontspec}
\setmainfont{DejaVu Sans}
\usepackage{graphicx}
\usepackage{booktabs}
\begin{document}
{\Large\bfseries \VAR{org_name|latex}} \hfill \VAR{certificate_number|latex}
\vspace{4mm}
\textbf{Asset:} \VAR{asset_id|latex} --- \VAR{asset_name|latex}\\
\textbf{Calibrated:} \VAR{calibration_date|latex} \quad \textbf{Due:} \VAR{due_date|latex}\\
\textbf{Performed by:} \VAR{performer_name|latex}
\BLOCK{if coefficient_rows}
\vspace{4mm}
\begin{tabular}{ll}
\toprule
\textbf{Coefficient} & \textbf{Value}\\
\midrule
\BLOCK{for row in coefficient_rows}
\VAR{row.coefficient|latex} & \VAR{row.value|latex}\\
\BLOCK{endfor}
\bottomrule
\end{tabular}
\BLOCK{endif}
\BLOCK{if performer_signature_path}
\vspace{8mm}
\includegraphics[width=35mm]{\VAR{performer_signature_path}}
\BLOCK{endif}
\end{document}Every generated certificate PDF — regardless of which template produced it, including this minimal one — is digitally signed after compilation; see Certificate digital signatures for how that works and how to verify it.
Example templates in the repo
Two complete, working templates ship in apps/api/app/templates/certificates/ as reference
material — both compile as-is and can be uploaded directly to try out:
default.tex.jinja— the built-in fallback. The canonical, always-up-to-date example of every placeholder in use.example_tables.tex— a denser, document-control-style alternative to the built-in default, covering the same content but laid out differently:- A document-control header (doc number, chapter, revision, sensor/calibration IDs, page) instead of a plain letterhead.
- Asset & channel information in three columns — a QR code of the asset, then the asset fields grouped two-per-column instead of split across separate label/value columns — plus a channel specification table covering measurement range, signal output range, accuracy, precision, and resolution.
- Fitted coefficients shown as a symbolic function (
function_formula, e.g.f(x) = a_0 + a_1 x) next to a table naming each coefficient (Offset, Gain, ...) with its value, rather than a single numeric equation. - A three-column Error & Uncertainty summary (Abs. Error / FS Error / Uncertainty) in one row, followed by the GUM explanatory paragraph and a conformity-derivation sentence that states the pass/fail conclusion against the numbers on the page.
- A full-width dataset table with a two-row header (column name, then unit) and no chart.
- A three-column footer repeated on every page: the preparer's name and signature on the left, a plain ISO/IEC 17025 issuance statement in the center (no conformity badge — the pass/fail conclusion lives in the results section instead), and the asset's organization name on the right.