Templates
ScaiScribe templates support DOCX, PPTX, and XLSX. The three paths differ in grammar:
- DOCX — full grammar via
docx-templatesv4 (FOR, IF, ALIAS, IMAGE, LINK, HTML, EXEC, plus JS expression evaluation). - PPTX — Tier 1 substitution only via the hand-rolled engine in
workers/node/lib/ooxml-template-fill.js. No FOR/IF/commands; no JS evaluation. Dotted paths only. - XLSX — same Tier 1 engine as PPTX. Inline strings AND shared strings are substituted.
Most of this page describes the DOCX grammar (the broadest surface). The "PPTX & XLSX (Tier 1)" section at the end covers what's supported on the other two formats.
If you just want the concept overview, see Concepts → Templates. For an end-to-end walkthrough, see Tutorials → Templates.
Delimiter#
ScaiScribe configures the engine with cmdDelimiter: ["{{", "}}"]. Every command lives inside {{ ... }} — there is no {% ... %} syntax, no Jinja-style block markers, no Mustache sections. One pair of double braces wraps everything.
1 2 3 4 5 6 7 8 9 10 | |
The JavaScript expression inside any command has full access to the variables passed in variables at instantiation time.
Insertion#
Three ways to spell it#
1 2 3 | |
All three produce identical output. The "shorthand" form is what you'll use 95% of the time.
Expressions, not just paths#
The content between the delimiters is a JavaScript expression. The last evaluated statement gets inserted:
1 2 3 4 5 | |
Multi-line expressions work too — the last value is what gets inserted:
1 2 3 4 | |
Nested paths#
{{ user.address.street }} resolves through the structure. Missing intermediate keys produce an error at render time (see Error handling).
If you want optional access, use JS optional chaining: {{ user?.address?.street ?? '—' }}.
Loops — FOR / END-FOR#
Iterate over arrays:
1 2 3 | |
Inside the loop, the current item is named with a $ prefix. Without the prefix, item would shadow… actually it just doesn't resolve cleanly. Always use the $ prefix inside loop bodies.
Loop variables#
{{ $idx }}— current 0-based index of the innermost loop.{{ $item }}(or whatever you named it) — the current element.
Nested loops#
1 2 3 4 5 6 | |
$idx always refers to the innermost loop. Save the outer index to a variable if you need it:
1 2 3 4 5 6 7 | |
Filtering and sorting inline#
The expression after IN is JavaScript:
1 2 3 | |
Loops over table rows#
Put the FOR marker in the row above the data row, and the END-FOR marker in the row below:
1 2 3 4 5 | |
docx-templates detects this and replicates the data row for each iteration. The marker rows themselves are removed from the output.
This works for arbitrary numbers of columns. It does NOT work for header rows (those aren't replicated).
Dynamic columns#
Loop horizontally too — FOR in the row above, END-FOR after the cell:
1 | |
Combine row + column loops for fully dynamic tables. See the docx-templates README for the cell-template pattern.
Empty arrays#
{{ FOR x IN [] }} … {{ END-FOR x }} produces no output and no error. The block is skipped. If you want fallback text, wrap in IF:
1 2 3 4 5 6 7 8 | |
Conditionals — IF / END-IF#
1 2 3 | |
The expression is plain JavaScript. Truthy values match — 0, "", null, undefined, false, NaN are falsy.
There is no ELSE. Use two separate IFs (or a ternary in an INS) for if-else:
1 2 | |
Or:
1 | |
IF over table rows#
Same row-marker pattern as FOR. Put IF in the row above the conditional content, END-IF in the row below.
Nested IFs#
1 2 3 4 | |
Internally IF is a FOR that iterates 0 or 1 times depending on the condition. They compose freely.
Aliases — ALIAS / *name#
Long expressions used in many places get unwieldy:
1 2 3 | |
Define aliases once, reference by *name:
1 2 3 4 5 6 | |
Aliases are particularly valuable in table rows where Word forces line-wrapping of long commands across multiple visual lines.
Images — IMAGE#
1 | |
The expression must return an image object:
1 2 3 4 5 6 7 8 9 10 | |
Important: ScaiScribe does not pass additionalJsContext#
The IMAGE command needs access to helper functions (like qrCode() above) via the additionalJsContext option to createReport(). ScaiScribe currently does NOT pass that option, which means IMAGE is only usable with literal/inline image objects:
1 | |
Pass the image data into your variables payload as a base64 string, then reference it via the expression. You'll still need exact width/height in cm.
For dynamic image generation (QR codes, charts as images, etc.), the workaround is to render the image in your application before instantiation and pass the bytes through.
Centering images#
Center the {{ IMAGE … }} command itself in the template (use Word's centre alignment on that paragraph). The image inherits the paragraph alignment.
Hyperlinks — LINK#
1 2 3 | |
The expression must return an object with url (required) and label (optional).
Raw HTML — HTML#
1 2 3 | |
Word supports this; LibreOffice and Google Docs do not (the altchunk feature is Word-specific). The HTML appears as a placeholder in those clients.
Avoid HTML for cross-client compatibility. If you need rich formatting, do it in the docx body directly or generate the formatted content as multiple commands.
Side effects — EXEC#
Run JavaScript without inserting output. Useful for setting variables you'll use later:
1 2 3 4 5 6 7 | |
EXEC is equivalent to {{= undefined }} — the expression evaluates, the output is empty.
ScaiScribe-specific notes#
What counts as a "placeholder" in the API response#
When you POST /v1/templates, the response includes a placeholders array — every {{ … }} token found in word/document.xml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
The list reports every token verbatim — including FOR/END-FOR/IF/END-IF/ALIAS markers and $-prefixed loop variables. It's not a "variable catalogue"; it's a structural inventory.
To extract just the variable names a caller needs to provide, filter out anything starting with FOR/END-FOR/IF/END-IF/ALIAS/EXEC/INS/= and anything starting with $.
Where placeholders are scanned#
ScaiScribe currently scans only word/document.xml for the API response. Placeholders in headers, footers, and side-zip parts ARE rendered correctly at instantiation time — the engine processes everything — but they don't surface in the API's placeholders list. If your operator UI relies on the list to validate variable bindings, account for this gap.
What ScaiScribe does NOT pass through#
1 2 3 4 5 | |
That's the full call. So these docx-templates features are unavailable:
additionalJsContext— no custom JS helpers;IMAGE/EXEC/INSsnippets can only use built-in JS (Math, Date, Array, etc.) and thedatapayload.processLineBreaks— defaults totrue; you can't disable it.failFast— defaults totrue; the first error stops processing.noSandbox— sandboxed by default; can't disable.- Custom error handlers — uses the default.
Maximum template size#
Templates are capped at the same per-file ceiling as other binary uploads (~32 MB). Most templates are well under 1 MB.
Quotas#
- Each registered template counts under
templates_countquota (default 20 per tenant). - Each instantiation produces a normal document, counted under
documents_count. - Instantiation doesn't have its own quota; just the per-day render budget via
renders_per_day.
Error handling#
ScaiScribe uses docx-templates with failFast: true (the default). The first error stops instantiation; the API returns a structured envelope:
1 2 3 4 5 6 7 8 | |
Common errors:
| Error | Cause | Fix |
|---|---|---|
Could not find … in path "X" |
Variable referenced but missing from variables payload. |
Add it, or use optional chaining: {{ X ?? '—' }}. |
Unexpected token ... |
Invalid JS expression inside {{ }}. |
Check the expression in a JS console; fix syntax. |
Unmatched END-FOR / Unmatched END-IF |
Missing or misplaced opening command. | Pair every FOR with END-FOR x (note the loop name). |
Maximum call stack size exceeded |
Infinite loop in an EXEC block. |
Add bounds. |
failFast means one mistake in one place kills the whole render. Test templates with realistic data before relying on them in production.
Limitations vs upstream docx-templates#
| Feature | docx-templates | ScaiScribe |
|---|---|---|
Custom additionalJsContext |
✓ | ✗ — only data and built-in JS |
| Custom command delimiters | ✓ | ✗ — fixed at {{ / }} |
QUERY command (GraphQL/SQL passthrough) |
✓ | ✗ — no queryResolver wired |
processLineBreaks toggle |
✓ | ✗ — always on |
noSandbox / VM sandbox toggle |
✓ | ✗ — always sandboxed |
| Custom error handlers | ✓ | ✗ — failFast: true always |
| PPTX templates | ✗ | ✓ Tier 1 — substitution only (no FOR/IF/JS) |
| XLSX templates | ✗ | ✓ Tier 1 — substitution only (no FOR/IF/JS) |
| XLSX templates | ✗ | ✗ — XLSX uses spec-based generation |
These are deliberate scope choices, not bugs. If your use case requires one of them, file a feature request.
PPTX & XLSX (Tier 2)#
PPTX and XLSX templates use a separate, smaller engine (workers/node/lib/ooxml-template-fill.js) rather than docx-templates. The reasons: no production-quality npm library covers PPTX templating; and XLSX templating libraries that exist are either tiny or unmaintained. Hand-rolled gives us the same {{ … }} syntax across all three formats with a predictable, debuggable code path.
What's supported#
| Feature | DOCX | PPTX | XLSX |
|---|---|---|---|
{{ var }} insertion |
✓ | ✓ | ✓ |
Dotted paths ({{ user.address.street }}) |
✓ | ✓ | ✓ |
Array indexing ({{ items.0.name }}, {{ items[0].name }}) |
✓ | ✓ | ✓ |
Method chains ({{ name.toUpperCase() }}, {{ amount.toFixed(2) }}) |
✓ (full JS) | ✓ (safe subset) | ✓ (safe subset) |
| Arithmetic + comparisons + logical ops + ternaries | ✓ (full JS) | ✓ | ✓ |
| Arrow functions / lambdas | ✓ | ✗ | ✗ |
Arbitrary globals (Math, Date, JSON) |
✓ | ✗ | ✗ |
| Missing variable → empty (no error) | ✗ (errors) | ✓ | ✓ |
| Missing variable → fidelity warning | n/a | ✓ (TEMPLATE_VARIABLE_MISSING) |
✓ (same) |
| Expression error → empty + warning | n/a | ✓ (TEMPLATE_EXPRESSION_ERROR) |
✓ (same) |
| Run-span coalescing for split tokens | (handled by docx-templates) | ✓ | ✓ |
FOR/END-FOR loops over text-frame paragraphs |
✓ | ✓ | n/a |
FOR/END-FOR loops over table rows |
✓ | n/a | ✓ (with formula-ref rewriting) |
FOR/END-FOR loops at slide level (whole-slide replication) |
n/a | ✓ | n/a |
IF/END-IF conditionals |
✓ | ✓ | ✓ |
IMAGE/LINK/HTML/EXEC commands |
✓ | ✗ | ✗ |
ALIAS |
✓ | ✗ | ✗ |
What gets scanned for placeholders#
The API's placeholders field reports tokens found in:
- DOCX:
word/document.xml - PPTX: every
ppt/slides/slideN.xml+ everyppt/notesSlides/notesSlideN.xml - XLSX:
xl/sharedStrings.xml(interned text) + everyxl/worksheets/sheetN.xml(inline strings)
Tokens in headers/footers (DOCX), master slides (PPTX), or other side parts still RENDER correctly at instantiation time. The scanner just doesn't surface them in the API field. If your operator UI relies on the list to validate variable bindings, account for the gap.
Expression language (PPTX/XLSX safe subset)#
The expression inside {{ … }} is parsed by a recursive-descent parser in workers/node/lib/template-expr.js. There is no eval, no VM, no native dependency. The parser walks the AST against your variables payload, applying methods only from a curated allowlist per type.
Supported syntax:
1 2 3 4 5 6 7 8 9 10 | |
Allowed methods, by receiver type:
| Receiver | Methods |
|---|---|
| string | toUpperCase, toLowerCase, trim, trimStart, trimEnd, slice, substring, substr, replace, replaceAll, split, concat, padStart, padEnd, startsWith, endsWith, includes, indexOf, charAt, charCodeAt, toString |
| number | toFixed, toPrecision, toExponential, toLocaleString, toString |
| array | slice, join, concat, includes, indexOf, at, reverse, toString, property length |
| string | property length |
| object | any property access (it's your data) |
Any method call outside the allowlist (e.g. name.eval("…"), items.filter(...), count.constructor) is rejected at evaluation time and the token renders as empty + a TEMPLATE_EXPRESSION_ERROR warning lands in the result. This is the safety boundary — there is no path through the parser that lets a user expression access global state, allocate VMs, or escape the data scope.
Loops — FOR / END-FOR#
Three placement contexts:
PPTX text-frame loop#
Markers occupy whole paragraphs within an <a:txBody>:
1 2 3 4 | |
Three items → three replicated lines. Inside the body, $item is the current element and $idx is the 0-based index of the innermost loop.
XLSX row loop#
Markers occupy whole rows. The block between replicates per item; rows are renumbered to be contiguous; formula cell refs that point at the template row get rewritten per replica:
1 2 3 4 5 6 7 | |
Three items → three data rows numbered 2, 3, 4; the totals row that started at row 5 in the template ends up at row 5 in the output. Relative cell refs in formulas get rewritten when their template row number equals the source row's number — absolute refs ($B$3) stay verbatim.
PPTX slide-level loop#
A slide-level FOR signals "clone this whole slide per data item":
- The slide's first text content must be
{{ FOR x IN list }}on its own - The slide's last text content must be
{{ END-FOR x }}on its own
The slide between them is cloned N times. Each clone gets its own scope binding $x to the current item. The presentation's relationships, content types, and <p:sldIdLst> are updated. Slides are renumbered 1..N (no gaps).
Empty input (x = []) drops the template slide entirely — no marker-only slide remains in the output.
Conditionals — IF / END-IF#
Same paragraph / row placement as FOR. Truthy → keep content, falsy → drop:
1 2 3 | |
{{ END-IF }} takes no name (vs. DOCX's {{ END-IF }} which also doesn't, despite the FOR naming difference).
Authoring PPTX/XLSX templates#
For PPTX, use PowerPoint or LibreOffice Impress. Type {{ recipient.name }} directly into text frames where you want substitution. Avoid pasting the placeholders — pasting from another app often splits the token across many runs and even our coalescing has limits.
For XLSX, use Excel or LibreOffice Calc. Type {{ subject }} into a cell. Both cell types (interned strings via sharedStrings.xml and inline strings via <is>) are handled.
For multi-row loops, put each marker on its own row. Mixing data and a {{ FOR }} marker in the same row is treated as substitution, not a loop start.
Numbers, dates, and currency#
You have two options now (Tier 2):
Option 1 — pre-format in your application (recommended for dates):
1 2 3 4 | |
Template: Due {{ deadline }} ({{ ratio_pct }}).
Option 2 — use the expression evaluator (good for currency / numeric formatting):
1 2 3 | |
Dates aren't easy here — there's no Date global, so use Option 1 for date formatting. Currency works in-template with method chains.
Where to next#
- Concepts → Templates — the model.
- Tutorials → Templates — end-to-end example.
- Troubleshooting → Templates — error catalogue.
- docx-templates upstream — the underlying library's own docs for things ScaiScribe doesn't override.