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 datasets

A 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.pdf

The 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:

FilePlannerLanguageBest for
composition.yamlYAML + jqdeclarativefixed structure with simple conditions and data reshaping
composition.jsJavaScript (goja)imperativedynamic 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 to false, 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 the simple_raw example). Handy for fixed cover pages or legal boilerplate.
  • Remote templates — a template.http.hbs that fetches a PDF from another service (see Remote templates). The remote-merge example 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):

CompositionPlannerDemonstrates
only_staticYAMLa fixed list, a transform.jq item, a conditional item, and an always-skipped if.jq: 'false' item
recursiveYAMLreferencing nested template names (recursive/level-1, …)
remote-mergeYAMLmerging a local page with a remotely-fetched PDF, conditional on the URL being present
js-exampleJavaScriptdynamic 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