# rusticdoc > rusticdoc is a self-hosted document service written in Rust, at version 0.1.4. It fills Office (.docx, .xlsx, .pptx, .odt, .ods, .odp), text, HTML and PDF form templates from JSON, converts the result with LibreOffice or Fulgur, and finishes it with PDF operations, in one multipart HTTP request or from the `rusticdoc` CLI. Things to know before writing templates or calls: - Templates use a Jinja-style syntax evaluated by minijinja: `{{ customer.name }}`, `{% for %}`, `{% if %}` and filters. It is not Carbone's `{d.field}` or docxtemplater's `{#items}` syntax. - A missing value stops the render with an error instead of printing nothing. `POST /v1/templates/validate` lists every problem in a template against sample data. - `image` takes base64 or a data URL and never fetches a URL. HTML is laid out by Fulgur with no browser, no JavaScript and no network. - There is no hosted service: every call goes to the reader's own deployment, usually on port 9027, with an `X-Api-Key` header when credentials are configured. - Agent skills for writing templates and calling the API are in the repository's `skills/` folder: https://github.com/crafted-systems/rusticdoc/tree/main/skills --- # Quickstart Source: https://rusticdoc.com/docs/start/quickstart/ Start the service, send it a Word template and some JSON, and get a filled invoice back as a PDF. You'll start the service, send it a Word template with some JSON, and get a filled invoice back as a PDF. All you need is Docker and `curl`. ## Start the service ```bash docker run -d --name rusticdoc \ --read-only --cap-drop ALL --security-opt no-new-privileges \ --tmpfs /tmp --tmpfs /home/rusticdoc:uid=1001,gid=1001 \ -p 127.0.0.1:9027:9027 ghcr.io/crafted-systems/rusticdoc:0.1.4 ``` There's no authentication set up yet, so anyone who can reach the port can use the service. That's why the command publishes it with `-p 127.0.0.1:`, which keeps it on your machine. [Install](https://rusticdoc.com/docs/start/install/) explains the other flags. Check that it's running: ```bash curl http://localhost:9027/ready ``` You should get JSON back that starts with `{"status":"up"`. ## Get the sample The sample is a Word file with tags like `{{ customer.name }}` typed into it, plus a JSON file with the values to fill them: ```bash curl -o invoice.docx https://rusticdoc.com/samples/invoice-template.docx curl -O https://rusticdoc.com/samples/invoice.json ``` ## Fill it and convert it ```bash curl -F template=@invoice.docx -F "data= items.txt <<'EOF' {% for i in items if i.qty -%} {{ loop.index }}/{{ loop.length }} {{ i.name }} {%- if not loop.last %}, {% endif %} {%- else -%} Nothing {%- endfor %} EOF cat > items.json <<'EOF' { "items": [ {"name": "Lamp", "qty": 2}, {"name": "Plug", "qty": 0}, {"name": "Desk", "qty": 1} ] } EOF curl -F template=@items.txt -F data=@items.json \ http://localhost:9027/v1/render -o out.txt grep -x '1/2 Lamp, 2/2 Desk' out.txt ``` The names on `loop` include `index` (from 1), `index0` (from 0), `first`, `last` and `length`. In an Office template, what repeats (text, a table row, paragraphs, sheet rows or slides) depends on where you put the opening and closing tags. See [Office formats](https://rusticdoc.com/docs/templates/office/). ## Conditions ```text {% if total > 1000 %}Large{% elif total > 0 %}Small{% else %}None{% endif %} ``` An empty list, an empty object, an empty string, `0`, `false` and `null` all count as false. ## Expressions | Kind | How you write it | |---|---| | Lookups | `customer.name`, `items[0].name`, `items[1:]` | | Literals | `"text"`, `12`, `1.5`, `true`, `[1, 2]`, `{"k": 1}` | | Arithmetic | `+`, `-`, `*`, `/`, `//` (whole division), `%`, `**` | | Joining text | `"INV-" ~ number` | | Comparison | `==`, `!=`, `<`, `<=`, `>`, `>=` | | Logic | `and`, `or`, `not`, `in`, `is` | | Inline choice | `"yes" if vip else "no"` | | Filters | `name \| upper`, `total \| currency("EUR")` | | Assignment | `{% set net = qty * price %}` | ### A filter binds tighter than arithmetic `a * b | currency("EUR")` applies the filter to `b` first and then multiplies a string by a number, which repeats the string. Put the arithmetic in brackets: ```bash cat > total.txt <<'EOF' {{ a * b | currency("EUR") }} {{ (a * b) | currency("EUR") }} EOF curl -F template=@total.txt -F 'data={"a":2,"b":3.5}' \ http://localhost:9027/v1/render -o out.txt test "$(cat out.txt)" = "$(printf 'EUR 3.50EUR 3.50\nEUR 7.00')" ``` Nothing flags the first line as a mistake, so check the totals the first time you render a template. ## Missing values are errors An undefined value stops the render. A missing variable never prints as nothing, so a typo can't reach a customer as a blank. ```bash printf 'Hello {{ nobody }}\n' > missing.txt curl -F template=@missing.txt -F 'data={"somebody":"Ada"}' \ http://localhost:9027/v1/render ``` You get a `422`: ```json { "error": "template_error", "message": "template error: undefined value (in :1) (line 1)" } ``` For an Office template, the message names the place instead of a line: ```text template error: undefined value (word/document.xml paragraph 2: {{ nobody }}) ``` All of these fail the same way when `a` is missing: - `{% if a %}`, and `{% if a.b %}` when `a` has no `b`. - `{% for i in a %}`. - A filter that needs a value, like `{{ a | upper }}` or `{{ a | currency }}`. There are three ways to let a value be absent: ```bash cat > optional.txt <<'EOF' {{ nickname | default("friend") }} {{ customer.phone | default("no phone") }} {% if vip is defined %}VIP{% else %}Regular{% endif %} EOF curl -F template=@optional.txt -F 'data={"customer":{"name":"Ada"}}' \ http://localhost:9027/v1/render -o out.txt test "$(cat out.txt)" = "$(printf 'friend\nno phone\nRegular')" ``` `default` only covers one missing level, so `{{ customer.phone.area | default("x") }}` still fails when `customer.phone` is missing. ### Print every missing value as nothing If your data is sparse by design, send `undefinedAsEmpty=true` to `/v1/render` or `/v1/render/batch`. For that request, every missing value prints as nothing, and so does every missing field of one. `date`, `currency`, `round` and `abs` print nothing for a missing value too, and a spreadsheet cell that holds only a missing value is left empty. ```bash printf 'Dear {{ customer.name }}{{ customer.title }}.' > sparse.txt curl -F template=@sparse.txt -F 'data={"customer":{"name":"Ada"}}' \ -F undefinedAsEmpty=true http://localhost:9027/v1/render -o out.txt test "$(cat out.txt)" = "Dear Ada." ``` It hides typos as well, so only use it where you expect blanks, and run [validation](https://rusticdoc.com/docs/templates/validate/) without it. ### Null isn't missing A key whose value is `null` is defined, so it doesn't raise an error and `default("x")` doesn't replace it. Use `default("x", true)` instead, which also replaces `""`, `0`, `false` and empty lists. A `null` prints as nothing in an Office template and as `None` in a text template. `true` and `false` print as `True` and `False` in both. ## Escaping Each format escapes values in its own way, so data can't add markup or keys of its own. | Template | What happens to a value | |---|---| | DOCX, ODT, XLSX, ODS, PPTX, ODP | `&`, `<` and `>` are escaped for XML, and characters XML forbids are dropped. A value can't break the file | | `.html`, `.htm`, `.xml`, `.svg` | `&`, `<`, `>`, `"`, `'` and `/` become character references, so a value is safe as text and in a quoted attribute | | `.json` | A value is written as JSON, with its own quotes: `{{ name }}` gives `"Ada"`, and `{{ n }}` gives `5` | | `.txt`, `.md`, `.csv` | Nothing is escaped | `{{ value | safe }}` writes a value as it is in any of these, so keep it away from data you didn't write. In an Office template it skips the XML escaping and writes the value into the document's XML, so a value with a `<` in it produces a broken file. Keep it away from data there. ```bash printf '

{{ name }}

{{ name | safe }}

' > page.html curl -F template=@page.html -F 'data={"name":"&"}' \ http://localhost:9027/v1/render -o page.out test "$(cat page.out)" = '

<b>&

&

' printf '{"name":{{ name }},"count":{{ count }}}' > out-template.json curl -F template=@out-template.json \ -F 'data={"name":"say \"hi\"","count":5}' \ http://localhost:9027/v1/render -o json.out jq -e '.name == "say \"hi\"" and .count == 5' json.out ``` In a `.json` template, don't put quotes around a tag. `"{{ name }}"` would come out with two sets of quotes, so the service refuses any `.json` template that doesn't render to valid JSON: ```bash printf '{"name":"{{ name }}"}' > quoted.json curl -F template=@quoted.json -F 'data={"name":"Ada"}' \ http://localhost:9027/v1/render -o error.json || true jq -e '.error == "template_error" and (.message | contains("remove any quotes"))' error.json ``` > **Where escaping doesn't help** > > Escaping makes a value safe as HTML or XML text. It doesn't make it safe inside a ` EOF jq --arg mark "$(base64 < mark.svg | tr -d '\n')" '. + {mark: $mark}' \ invoice.json > with-mark.json curl -F template=@tagged.docx -F "data= with-url.json curl -F template=@tagged.docx -F "data= photo.json curl -F template=@badge.docx -F "data= out.xml grep -o '' out.xml grep -o '' out.xml ``` When a loop repeats a table row that has a placeholder in it, each copy gets its own image, as it would with `{{ item.photo | image }}`. XLSX is different, because pictures there live in a drawing beside the sheet. A placeholder in an XLSX sees the data's top level but not loop variables, and a row loop doesn't copy it. Only pictures can be placeholders, so a tag in the alt text of a shape, chart or text box is refused with `only a picture can be a placeholder`. Errors name the picture, for example `picture "Picture 1"`. ### fit `fit` decides how an image sits in a box with a different shape from its own. That box is either a placeholder's frame or a `width` and `height` given together. | Value | Result | |---|---| | `contain` | The whole image, as large as fits, centred, with its aspect ratio kept. The rest of the box is left empty. The default in a placeholder | | `cover`, `fill` | The box is filled, with the aspect ratio kept and the overflow cropped equally from both sides | | `width` | The box keeps its width, and its height changes to follow the image's aspect ratio | | `stretch` | The image is stretched to the box. The default when `width` and `height` are given together | Cropping is done the way the office suites do it, with `a:srcRect` in DOCX, XLSX and PPTX and `fo:clip` in ODF. The full image stays in the document, so the crop can be undone in Word, PowerPoint, Excel or LibreOffice. In XLSX, `fit="width"` turns a two-cell anchor into a one-cell anchor so that the height can change. ## barcode ```text {{ sku | barcode("code128") }} {{ url | barcode("qr", size=90) }} {{ gtin | barcode("ean13", text=true) }} {{ ticket | barcode("pdf417", width=220, ec=5) }} ``` The first argument names the symbology. It's required, and there's no default. | Symbology | Accepts | |---|---| | `qr` | Any text a QR code can hold, encoded as UTF-8 | | `datamatrix` | ASCII text, up to what the largest Data Matrix holds | | `pdf417` | Any text a PDF417 symbol can hold | | `aztec` | Any text an Aztec code can hold | | `code128` | Printable ASCII | | `gs1-128` | Application identifiers in parentheses, like `(01)09501101530003(10)ABC123` | | `code39` | Upper-case letters, digits, space and `- . $ / + %`. `lower` is refused | | `code93` | Printable ASCII | | `ean13` | 12 digits, or 13 with a correct check digit | | `ean8` | 7 digits, or 8 with a correct check digit | | `upca` | 11 digits, or 12 with a correct check digit | | `upce` | 7 digits starting with 0 or 1, or 8 with a correct check digit | | `itf` | An even number of digits, at most 80. Add a leading 0 to an odd one | | `itf14` | 13 digits, or 14 with a correct check digit. Bearer bars are drawn above and below | | `codabar` | Digits and `- $ : / . +`, between optional start and stop letters A to D | If a check digit is wrong, the error gives the right one: `"5901234123458" has a wrong check digit: EAN-13 expects 7`. A value that's too long for its symbology gives `the value is too long for a QR code`, and so on. For `gs1-128`, each identifier is 2 to 4 digits in parentheses, followed by its data. Identifiers whose data has a fixed length in the GS1 tables, like `(01)` and `(17)`, are checked for that length, and `(00)`, `(01)` and `(02)` are checked for their check digit. The service puts the FNC1 characters where GS1 wants them, which is at the start and after each variable-length field that's followed by another. The text under the bars shows the parentheses. | Argument | Meaning | |---|---| | `size` | Printed width in points, above 0 and at most 14400. The default is 144 for `qr`, `datamatrix`, `pdf417` and `aztec`, and 200 for the others | | `width` | The same as `size`. Give one or the other | | `height` | Printed height in points. A linear code's bars take whatever the text leaves. A `qr`, `datamatrix` or `aztec` code is square, so `height` alone sets both sides, and a `width` and `height` that differ are refused. A `pdf417` symbol stretches its rows to the height | | `text` | `true` prints the value under a linear code. EAN and UPC codes print their digits between guard bars that reach down, with the leading and trailing digits outside the bars. The default is `false`, and two-dimensional codes refuse it | | `font_size` | The text's size in points. The default is ten modules or an eighteenth of the width, whichever is smaller, kept between 6 and 12 points | | `color` | The colour of the bars, as `#rgb` or `#rrggbb`. The default is black | | `background` | The space around the bars, as a colour or `transparent`. The default is white | | `quiet_zone` | The empty margin on each side, in modules, from 0 to 100. The default is 10 for linear codes, 4 for `qr` and 2 for the other two-dimensional codes | | `rotate` | `0`, `90`, `180` or `270` degrees clockwise. The printed width and height swap at 90 and 270 | | `ec` | Error correction: `L`, `M`, `Q` or `H` for `qr` (default `M`), a percentage from 5 to 95 for `aztec`, a level from 0 to 8 for `pdf417`. Other symbologies refuse it | | `fit` | How the code sits in a placeholder picture. See [fit](#fit) | The sample invoice already has a QR code in it, so you can see one without changing anything: ```bash curl -F template=@invoice.docx -F "data= with-gtin.json curl -F template=@tagged.docx -F "data= with-link.json curl -F template=@tagged.docx -F "data=' ``` This shades a paragraph: ```bash mkdir t && cd t && unzip -q ../invoice.docx export TAG='{{ number | color(background="#fee", scope="paragraph") }}' perl -pi -e 's/Billed to/$ENV{TAG}/' word/document.xml zip -qr ../tagged.docx . && cd .. curl -F template=@tagged.docx -F "data=' ``` ```bash mkdir t && cd t && unzip -q ../invoice.docx export TAG='{{ number | color(background="#fee", scope="shape") }}' perl -pi -e 's/Billed to/$ENV{TAG}/' word/document.xml zip -qr ../tagged.docx . && cd .. curl -F template=@tagged.docx -F "data=Hi` gives a paragraph holding `Hi` and `` gives nothing. - No attribute is copied except a checked `href` and colour, so a `span` with any other style loses the style. - An `a` with a scheme that `link` would refuse is an error. Apart from that, broken HTML is closed as well as it can be and never refused. What you get depends on the format: | Format | Result | |---|---| | DOCX | Native paragraphs and tables. The tag's paragraph is replaced by generated ones with its properties, and text around the tag stays in paragraphs of its own. Lists are indents with a literal bullet or number. Headings are bold and sized | | ODT | Native paragraphs, lists and tables, with the same handling of text around the tag | | PPTX, XLSX, ODS, ODP | Plain text, with a line break per paragraph, item or row. Bullets and numbers stay, and table cells are separated by tabs. XLSX also sets wrap text on the cell | In DOCX the tag has to sit directly in a paragraph, not inside a hyperlink or content control. ```bash mkdir t && cd t && unzip -q ../invoice.docx perl -pi -e 's/Billed to/{{ note | html }}/' word/document.xml zip -qr ../tagged.docx . && cd .. jq '. + {note: "

Thanks for your order

"}' \ invoice.json > with-note.json curl -F template=@tagged.docx -F "data= out.xml grep -o 'Thanks for your ' out.xml ! grep 'bad()' out.xml ``` ## chart ```text {{ rows | chart(type="bar", x="month", y=["sales", "costs"], title="Revenue") }} ``` The value is a list of objects, and each object is one category. | Argument | Required | Meaning | |---|---|---| | `type` | Yes | `bar`, `column`, `line` or `pie` | | `x` | Yes | The key holding each category's label | | `y` | Yes | A key, or a list of keys for several series. A `pie` takes one | | `title` | No | The chart's title | | `width`, `height` | No | Size in points. The default is 450 by 270 | ```bash mkdir t && cd t && unzip -q ../invoice.docx export TAG='{{ rows | chart(type="column", x="month",' TAG+=' y=["sales"], title="Sales") }}' perl -pi -e 's/Billed to/$ENV{TAG}/' word/document.xml zip -qr ../tagged.docx . && cd .. jq '. + {rows: [ {month: "March", sales: 90}, {month: "April", sales: 120} ]}' invoice.json > with-rows.json curl -F template=@tagged.docx -F "data=