Compositions
A composition renders several templates and merges their output into a single PDF. Instead of one template producing one document, a composition produces a plan — an ordered list of (template, data) tasks — renders each task, and concatenates the resulting PDFs into one file.
Use compositions when a document is naturally assembled from parts: a cover page plus a body plus an appendix, a per-line-item report, a bundle that stitches in a statically-provided or remotely-fetched PDF, or any case where the number and shape of pages depends on the input data.
Where compositions live
Compositions are loaded from a separate directory — compositions/ by default (--compositions-dir / COMPOSITIONS_DIR). Each composition is a directory:
compositions/
invoice-bundle/
composition.yaml # OR composition.js — the plan definition (required)
schema.json # optional: validates the request data
examples/
minimal.json # optional: example datasetsA composition is addressed by its directory name, exactly like a normal template:
curl -X POST -H 'Content-Type: application/json' \
-d '{ ... }' \
http://localhost:9999/templates/invoice-bundle/render > bundle.pdfThe templates a composition references are ordinary templates from your templates/ directory (including nested names when --templates-recursive is set, raw pre-built PDFs, and remotely-fetched documents).
Two planner engines
The plan can be expressed two ways. PDF Server picks the first one it finds, in this order:
| File | Planner | Language | Best for |
|---|---|---|---|
composition.yaml | YAML + jq | declarative | fixed structure with simple conditions and data reshaping |
composition.js | JavaScript (goja) | imperative | dynamic structure, loops, richer logic |
YAML planner (composition.yaml)
A templates: list. Each item names a templateName and may carry two optional jq expressions:
if.jq— a boolean condition. If it evaluates tofalse, the item is skipped.transform.jq— an expression returning an object used as that child’s render data. Without it, the child receives the whole request data.
templates:
# Always included, receives the full request data.
- templateName: static_template
# Reshape the data passed to this child.
- templateName: mustache_template
transform:
jq: '.embedded'
# Include only when there is something to show.
- templateName: lorem-ipsum
if:
jq: '.iterations | length > 0'jq is evaluated with gojq. The condition must evaluate to a boolean; the transform must evaluate to an object.
JavaScript planner (composition.js)
Export a buildPlan(data) function returning an array of { templateName, data } tasks. This gives you loops, conditionals, and helpers — useful when the number of tasks depends on the data.
// composition.js
var buildTask = require('./static.js').buildTask;
exports.buildPlan = function (data) {
var plan = [{ templateName: 'static_template', data: data }];
if (data.iterations && data.iterations.length) {
data.iterations.forEach(function (item) {
plan.push({ templateName: 'lorem-ipsum', data: item });
});
}
if (data.embedded) {
plan.push(buildTask('mustache_template', data.embedded));
}
return plan;
};The JavaScript runtime is goja (pure-Go, no Node.js). It supports CommonJS require('./file.js') of sibling files and console.log/warn/error (routed to the server log). It has no filesystem or network access — the planner only decides what to render, not how.
Preview a plan without rendering
For composite templates you can inspect the plan the planner would produce for a given input, without rendering any PDF:
curl -X POST -H 'Content-Type: application/json' \
-d '{ "iterations": [1, 2], "embedded": { "value": "hi" } }' \
http://localhost:9999/templates/invoice-bundle/preview/render-plan[
{ "template_name": "static_template", "data": { ... } },
{ "template_name": "lorem-ipsum", "data": 1 },
{ "template_name": "lorem-ipsum", "data": 2 },
{ "template_name": "mustache_template","data": { "value": "hi" } }
]This is invaluable when debugging a planner: you see exactly which templates run and what data each receives. The endpoint returns 422 if the request data fails the composition’s own schema.json, and is only available for composite templates.
How the PDFs are merged
Each task is rendered independently (concurrently, through the render pool), and the resulting PDFs are concatenated in plan order using pdfcpu. A plan with a single task skips the merge and returns that PDF directly. Each source PDF is read and validated before merging; PDF 2.0 inputs are currently rejected by the merger.
Mixing local, raw, and remote documents
Because a composition just references templates by name, its tasks can be any template type:
- Rendered templates — the usual HTML→PDF templates.
- Raw templates — a directory containing a pre-built
raw.pdf, returned as-is (see thesimple_rawexample). Handy for fixed cover pages or legal boilerplate. - Remote templates — a
template.http.hbsthat fetches a PDF from another service (see Remote templates). Theremote-mergeexample stitches a locally-rendered page together with a remotely-fetched PDF.
Worked examples
The repository ships four compositions under examples/compositions/ (rendered live in the Gallery):
| Composition | Planner | Demonstrates |
|---|---|---|
only_static | YAML | a fixed list, a transform.jq item, a conditional item, and an always-skipped if.jq: 'false' item |
recursive | YAML | referencing nested template names (recursive/level-1, …) |
remote-merge | YAML | merging a local page with a remotely-fetched PDF, conditional on the URL being present |
js-example | JavaScript | dynamic plan building, require('./static.js'), console logging, per-item child data |
Validation note
The composition’s own schema.json validates the request data. Be aware that each child template’s own schema.json is not re-validated when it is reached through a composition — validate at the composition boundary, or keep child templates tolerant of the shapes the planner produces.
See also
- Remote templates — fetch a PDF from another service.
- Data validation — JSON Schema for request data.
- API reference — the
renderandpreview/render-planendpoints.