PaperStudio/PaperStudio Variables
UsuarioSoporteDeveloper

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) with autoescape: 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 for html cells).


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_json can override them → reserved _-prefixed objects are applied last and always win. So a photos key in your own data_json replaces 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.url }})

*{{ 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 %}![]({{ p.url }}){% 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 ![alt](https://…) embeds inside a markdown cell. 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 an image cell there.

![Una foto]({{ photos.0.url }})

{# a grid straight from the acervo #}
{% for p in photos %}![{{ p.caption }}]({{ p.url }})
{% 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 ![](url) 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 ![](url) 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 }} — not to_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 in inline code or the {{ '…' }} escape.
  • For rich blocks (real code listings, callouts, complex layout) use a typst cell — 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, photos iteration, remote images) is seeded by docuget_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 %}
![{{ p.caption }}]({{ p.url }})
{% 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 }}**
![evidencia]({{ entrega.src }})

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