PaperStudio Variables
Every papercell of type markdown (and echart, image, html, svg,
typst, table) is run through
Nunjucks before it is rendered. That
means you can inject dynamic data with {{ variable }} expressions, loops,
filters and conditionals — exactly like a Nunjucks template.
This page documents which variables are available, their shape, and
how to inspect them when a cell prints something unexpected like
[object Object].
The engine is real Nunjucks (
nunjucks_utils.ts) withautoescape: false. A cell's text is expanded by Nunjucks first, then the result is compiled by the downstream renderer (markdown → PDF, typst → PDF, satori → SVG forhtmlcells).
Why {{ photos }} prints [object Object]
photos is not a string — it is an array of objects. When you drop an
object (or array of objects) directly into {{ }}, Nunjucks coerces it with
String(value), which yields [object Object]. That is expected JavaScript
behavior, not a bug.
To see the actual content, pipe it through a JSON filter:
{{ photos | dump(2) }} {# nunjucks built-in — JSON.stringify with indent 2 #}
{{ photos | to_json(2) }} {# docuget filter — same result #}
To use the data, iterate it instead of printing it raw (see
The photos array).
Flat aliases (the everyday variables)
These are injected at the top level of every cell's context. They are the ones you'll use 95% of the time:
| Variable | Type | Meaning |
|---|---|---|
{{ doc_title }} |
string | The studio's title |
{{ creation_date }} |
string | Document creation date, YYYY-MM-DD |
{{ page_number }} |
number | Current page number (1-based) |
{{ page_total }} |
number | Total number of pages |
{{ photos }} |
array | Image assets from the studio's acervo (see below) |
Anything you put in the studio's data_json (the Datos tab) is also
merged in flat, so if your data_json is
{ "customer": "ACME", "folio": 42 } you can write {{ customer }} and
{{ folio }} directly.
Precedence: flat aliases are applied first → your
data_jsoncan override them → reserved_-prefixed objects are applied last and always win. So aphotoskey in your owndata_jsonreplaces the acervo alias, but you can never accidentally clobber_doc/_acervo.
Reserved context objects (the full data)
For everything the flat aliases don't cover, four reserved objects carry the
complete, authoritative context. These are always present and cannot be
overridden by data_json:
| Object | Contents |
|---|---|
_doc |
{ id, title, creation_date, created_at, page_count } |
_page |
{ number, total, index, name, is_first, is_last, is_odd, is_even } |
_studio |
{ title, locale, data } — data is your full data_json |
_acervo |
{ assets, photos, count } — the full acervo bag (all kinds, not just images) |
Examples:
{% if _page.is_first %}# {{ doc_title }}{% endif %}
Page {{ _page.number }} of {{ _page.total }}
{% if _page.is_odd %}(odd page){% endif %}
Locale: {{ _studio.locale }}
The photos array
photos is the image-like subset of the studio's acervo — assets whose kind
is photo, image or render. Each element has this shape:
{
id: string,
kind: string, // "photo" | "image" | "render"
url: string, // short-lived signed GET URL (≈1h expiry)
caption: string,
tags: string[],
responsable: string | null
}
Iterate it — don't print it raw:
{% for p in photos %}

*{{ p.caption }}* — {{ p.responsable or "s/autor" }} {{ p.tags | join(", ") }}
{% endfor %}
Guard for the empty case (a studio with no acervo images yields photos: []):
{% if photos | length %}
{% for p in photos %}{% endfor %}
{% else %}
_No hay fotos en el acervo todavía._
{% endif %}
For the full acervo (documents, videos, any kind — not just images) use
_acervo.assets.
Images (including remote URLs)
As of docuget_api 0.2.200, the markdown image syntax
embeds inside amarkdowncell. The converter fetches each image URL (loadAsset:http(s):///data:/gen:/ acervo) into the PDF, mirroring the mermaid/echart bake path. On older deploys it rendered as a blue link — use animagecell there.

{# a grid straight from the acervo #}
{% for p in photos %}
{% endfor %}
Each embedded image is emitted at width: 100% of its cell/column, so for a
grid give each image its own line (or a narrow column cell). The fetch is
SSRF-guarded — a URL pointing at a loopback/private/link-local/metadata
address (e.g. 169.254.169.254) is refused and falls back to a styled link
instead of a picture. A failed or non-image URL also falls back to a link.
For deliberate, positioned figures (fixed box, fit control, captions) use an
image cell — its content.src accepts the same sources and is
Nunjucks-applied:
{
"type": "image",
"content": { "src": "https://picsum.photos/640/420", "fit": "contain" }
}
fit can be cover · contain · fill · stretch.
Inside a typst cell you can also inline a remote image with a filter:
{{ "https://picsum.photos/300" | typst_png_image }}
{# emits #image(base64.decode("…"), format:"png") #}
| Want a remote image in… | Do this |
|---|---|
a markdown cell |
✅  embeds (0.2.200+); one image per line for grids |
an image cell |
✅ set content.src to the URL — positioned figure with fit |
a typst cell |
✅ {{ url | typst_png_image }} or {{ url | img_url_b64 }} |
Markdown in cells — what renders
A markdown cell is compiled markdown → typst → PDF by a lightweight
line-based converter, not a full CommonMark engine. That means a specific,
limited subset renders:
| ✅ Renders | ❌ Does not render |
|---|---|
Headings #, ##, ### |
Fenced code blocks ```lang (shown literally, newlines collapse) |
**bold** |
Italics *x* / _x_ (shown literally) |
`inline code` |
Blockquotes > … (the > prints literally) |
| Tables (incl. Nunjucks-generated rows) | (images  now embed — see above) |
[text](url) links |
Indented (4-space) code blocks |
Paragraphs, --- rules, bullet lists |
Multi-line preservation — single newlines collapse |
Practical consequences:
- JSON dumps: because fenced blocks and newlines don't survive, print
objects with the compact filter —
{{ obj | to_json }}— notto_json(2). The indented form just collapses into messy spacing. Compact JSON wraps cleanly as normal text. - Showing literal
{{ }}: wrap it in the escape trick —{{ '{{ page_number }}' }}outputs the literal text{{ page_number }}. Use this for documenting syntax inside a cell. - Nunjucks-generated tables work — build rows in a loop with whitespace
trimming so each row is one line:
| # | caption | tags | |---|---|---| {% for p in photos -%} | {{ loop.index }} | {{ p.caption }} | {{ p.tags | join(", ") }} | {% endfor -%} - Avoid literal
](in prose. A[text](url)sequence anywhere on a line is parsed as a link and emitted as typst#link(...); writing it as plain example text can unbalance delimiters and fail the typst compile. Wrap such examples ininline codeor the{{ '…' }}escape. - For rich blocks (real code listings, callouts, complex layout) use a
typstcell — its source passes through unescaped and you get the full typst language.
A ready-made "Variables & Nunjucks — Demo" PaperStudio (4 pages: flat aliases, context inspector,
photositeration, remote images) is seeded bydocuget_cli/examples/seed_paperstudio_variables_demo.ts— a live companion to this page.
Inspecting the context — the "data inspector" cell
When you're not sure what's available or what a value looks like, drop a
temporary markdown cell with the following body. It prints the entire
resolved context as JSON so you can copy exact keys and values (URLs come out
already signed). Use the compact to_json (not to_json(2)) — inside a
markdown cell newlines collapse, so compact JSON reads far better:
### 🔍 Context inspector
**_doc** → {{ _doc | to_json }}
**_page** → {{ _page | to_json }}
**_studio** → {{ _studio | to_json }}
**photos** → {{ photos | to_json }}
**_acervo** → {{ _acervo | to_json }}
Delete the cell once you've captured what you need.
JSON & data filters reference
All available in any cell. dump is the Nunjucks built-in; the rest are docuget
filters registered in nunjucks_utils.ts.
| Filter | Example | Result |
|---|---|---|
dump |
{{ photos | dump(2) }} |
Pretty JSON (built-in) |
to_json |
{{ photos | to_json(2) }} |
Pretty JSON (docuget) |
object |
{{ obj | object }} |
Compact JSON |
toJSON |
{{ obj | toJSON }} |
Compact JSON |
to_yaml |
{{ obj | to_yaml }} |
YAML |
to_toml |
{{ obj | to_toml }} |
TOML |
to_csv |
{{ rows | to_csv }} |
CSV |
to_md_table |
{{ rows | to_md_table }} |
Markdown table |
length |
{{ photos | length }} |
Item count |
join |
{{ tags | join(", ") }} |
Joined string |
Plus the standard Nunjucks built-ins (default, replace, upper, lower,
title, first, last, slice, round, int, etc.) and docuget's
formatting filters (number, money2number, iva, isodate, localdate,
ddmmyyyy_to_iso, qrcode, img_url_b64, …).
Reactive cell ports — one cell reads another
Beyond the data bag, every cell that has a name publishes output ports —
reachable from any other cell as {{ name.port }}. This is what lets a document
compute on itself: a table that reads a map's stats, a paragraph that quotes a
photo's answer.
Name the cell in the inspector (mapa, foto, resumen), then reference it.
An unnamed cell publishes nothing; a reference to a missing port resolves to
empty (never an error).
| Cell type | Ports |
|---|---|
map |
spec · bbox · geojson · features · total_points · total_density · by_entidad |
table |
columns · rows · count |
markdown |
text |
echart |
option |
image |
src · gen_ref · answer · qa |
svg |
svg |
image: ask the photo, and the answer becomes data
An image cell has a ✨ Preguntar a la imagen box: type a question
("¿cuántas cajas hay?", "¿cuál es el total?"), hit ask, and the answer is stored
in the cell's own JSON (content.vision.qa). It then shows up as ports:
answer— the last answer (the common case: one question, referenced short)qa— the full list of{q, a, model, at}, to iterate
<!-- image cell named "foto" -->
Se contaron **{{ foto.answer }}** cajas en la entrega.
{% for item in foto.qa %}
- {{ item.q }} → {{ item.a }}
{% endfor %}
The vision call runs through the platform's vision provider and is metered like any other AI call (see AI › Visión). The answer is saved once, so the PDF renders from stored data — it does not re-ask the model on every render.
Built-in functions
Available alongside the ports (they win on a name clash, so avoid naming a cell after one):
| Function | What it does |
|---|---|
geojson2table(geojson, opts?) |
features → a Markdown table (opts: {fields, limit}) |
geojson_count(geojson) |
number of features |
pluck(geojson, field) |
array of one property across features |
escala(ingrediente, porciones) |
scales a recipe ingredient (food module) |
escala_num(ingrediente, porciones) |
same, numeric only |
{{ geojson2table(mapa.geojson, { fields: ['nombre', 'total'], limit: 20 }) }}
Total de puntos: {{ geojson_count(mapa.geojson) }}
Quick recipes
Header that only shows on the first page:
{% if _page.is_first %}
# {{ doc_title }}
Generado el {{ creation_date }}
{% endif %}
Footer with page numbering:
Página {{ page_number }} de {{ page_total }}
Photo grid from the acervo:
{% for p in photos %}

{% endfor %}
Debug a single value inline:
customer = `{{ customer | to_json }}`
Report inside the report — photo → question → paragraph:
<!-- markdown cell, reading an image cell named "entrega" -->
## Recepción de mercancía
Conteo declarado por la foto: **{{ entrega.answer }}**

How it fits together
flowchart TD
A["paper_studio.data_json<br/>(Datos tab)"] --> M["Context bag<br/>(merged)"]
B["Acervo assets<br/>photo / image / render"] --> P["photos[]"]
P --> M
C["Per-page vars<br/>page_number, _page"] --> M
D["Reserved objects<br/>_doc · _studio · _acervo"] --> M
E["Named cells<br/>mapa.geojson · foto.answer"] --> M
F["Built-ins<br/>geojson2table · escala"] --> M
M --> N["Nunjucks render<br/>{{ vars }} · filters · loops"]
N --> R{"Cell type"}
R -->|markdown| PDF["Markdown → PDF"]
R -->|typst| TY["Typst → PDF"]
R -->|html| SV["Satori → SVG"]
style M fill:#0f172a,color:#fff
style N fill:#059669,color:#fff
style P fill:#4f46e5,color:#fff
style E fill:#7c3aed,color:#fff
See also
- Render Engine — the full rendering pipeline
- Template System — reusable templates (also Nunjucks)
- Diagram Templates — mermaid/diagram cells