Calculations
This page shows how to calculate inside a template: arithmetic, totals and averages, counts, totals per group, running balances, lookups between lists, and dates. Everything here works in Office, text and PDF templates. It ends with a worked invoice.
You can also do the sums in your application and send the results in data. Doing them in the template keeps the data plain and the layout’s logic in one place.
Arithmetic
Section titled “Arithmetic”| Operator | Does | Example | Prints |
|---|---|---|---|
+, -, * |
Add, subtract, multiply | {{ 2 + 3 * 4 }} |
14 |
/ |
Divide, always giving a decimal | {{ 10 / 4 }}, {{ 4 / 2 }} |
2.5, 2.0 |
// |
Divide and round down | {{ 10 // 3 }}, {{ -7 // 2 }} |
3, -4 |
% |
Remainder | {{ 10 % 3 }} |
1 |
** |
Power | {{ 2 ** 10 }} |
1024 |
( ) |
Group | {{ (a + b) * c }} |
cat > math.txt <<'EOF'{{ (a + b) * c }}{{ a + b * c }}{{ 10 / 4 }}{{ 10 // 3 }}{{ 10 % 3 }}{{ 2 ** 10 }}EOFcurl -F template=@math.txt -F 'data={"a":1,"b":2,"c":3}' \ http://localhost:9027/v1/render -o out.txttest "$(cat out.txt)" = "$(printf '9\n7\n2.5\n3\n1\n1024')"Precedence
Section titled “Precedence”From the loosest to the tightest:
orandnot- Comparisons:
==,!=,<,<=,>,>=,in,not in,is +,-~, which joins text*,/,//,%**, read from left to right:2 ** 3 ** 2is64- A filter:
|
Three of these surprise people:
- A filter binds tightest.
a * b | currencyformatsbalone. Write(a * b) | currency. See a filter binds tighter than arithmetic. ~binds tighter than+."INV-" ~ n + 1joins first and then fails to add a number to text. Write"INV-" ~ (n + 1).- A minus sign binds tighter than
**.-2 ** 2is4.
When in doubt, add brackets.
cat > order.txt <<'EOF'{{ "INV-" ~ (n + 1) }}|{{ (a * b) | round(1) }}|{{ 2 ** 3 ** 2 }}EOFcurl -F template=@order.txt -F 'data={"n":41,"a":2,"b":3.33}' \ http://localhost:9027/v1/render -o out.txttest "$(cat out.txt)" = 'INV-42|6.7|64'Decimals
Section titled “Decimals”Numbers with decimals are binary floating point, as in most languages, so 0.1 + 0.2 prints 0.30000000000000004. Round before you print: round(2) or currency does it.
Dividing by zero stops the render with unable to calculate 1 / 0. Guard it with a condition: {{ (done / total * 100) | round if total else 0 }}.
Rounding
Section titled “Rounding”| Filter | Example | Prints |
|---|---|---|
round |
{{ 2.567 | round(2) }} |
2.57 |
round with a method |
{{ 2.561 | round(2, "ceil") }}, {{ 2.569 | round(2, "floor") }} |
2.57, 2.56 |
ceil |
{{ 2.1 | ceil }}, {{ -2.1 | ceil }} |
3, -2 |
floor |
{{ 2.9 | floor }}, {{ -2.1 | floor }} |
2, -3 |
abs |
{{ (0 - 5) | abs }} |
5 |
round always gives a decimal, and sends a half to the even neighbour: {{ 2.5 | round }} prints 2.0, {{ 3.5 | round }} prints 4.0, and {{ 0.125 | round(2) }} prints 0.12. currency sends a half away from zero instead, so {{ 0.125 | currency }} prints 0.13. Use the same one for every figure on a document, or totals may differ by a cent.
cat > round.txt <<'EOF'{{ 2.567 | round(2) }}{{ 2.561 | round(2, "ceil") }}{{ 2.569 | round(2, "floor") }}{{ -2.1 | ceil }}{{ -2.1 | floor }}{{ 2.5 | round }}{{ 3.5 | round }}{{ 0.125 | round(2) }}{{ 0.125 | currency }}EOFcurl -F template=@round.txt -F 'data={}' \ http://localhost:9027/v1/render -o out.txttest "$(cat out.txt)" = \ "$(printf '%s\n' 2.57 2.57 2.56 -2 -3 2.0 4.0 0.12 0.13)"ceil and floor give a whole number, which prints without .0, so use them for counts such as the boxes needed for 25 items, 12 to a box:
cat > boxes.txt <<'EOF'{{ (items / per_box) | ceil }} boxes, {{ items // per_box }} fullEOFcurl -F template=@boxes.txt -F 'data={"items":25,"per_box":12}' \ http://localhost:9027/v1/render -o out.txttest "$(cat out.txt)" = '3 boxes, 2 full'Totals, averages, minimum and maximum
Section titled “Totals, averages, minimum and maximum”For a list of objects, name the attribute to add up. For these examples lines is:
[ {"name": "Lamp", "group": "Home", "qty": 2, "price": 19.5}, {"name": "Tea", "group": "Food", "qty": 3, "price": 4}, {"name": "Rice", "group": "Food", "qty": 1, "price": 2.5}]| Want | Write | Gives |
|---|---|---|
| Total | lines | sum(attribute="price") |
26.0 |
| Average | lines | avg(attribute="price") |
8.666… |
| Lowest and highest value | lines | map(attribute="price") | min |
2.5 |
| The item with the lowest value | (lines | min(attribute="price")).name |
Rice |
| Number of items | lines | length |
3 |
cat > totals.txt <<'EOF'{{ lines | sum(attribute="price") }}{{ lines | avg(attribute="price") | round(2) }}{{ lines | map(attribute="price") | min }}{{ lines | map(attribute="price") | max }}{{ (lines | max(attribute="price")).name }}{{ lines | length }}EOFcat > lines.json <<'EOF'{ "lines": [ {"name": "Lamp", "group": "Home", "qty": 2, "price": 19.5}, {"name": "Tea", "group": "Food", "qty": 3, "price": 4}, {"name": "Rice", "group": "Food", "qty": 1, "price": 2.5} ]}EOFcurl -F template=@totals.txt -F data=@lines.json \ http://localhost:9027/v1/render -o out.txttest "$(cat out.txt)" = "$(printf '26.0\n8.67\n2.5\n19.5\nLamp\n3')"- A list of plain numbers needs no attribute:
{{ [1, 2, 4] | sum }},{{ [1, 2, 4] | avg }}. - An attribute can be a path:
sum(attribute="totals.net"). sumskips items that lack the attribute, and gives0for an empty list.avgskips items whose value is missing ornull, and givesnullfor an empty list, so{{ lines | avg(attribute="price") | default(0, true) }}prints0then.minandmaxof an empty list are undefined, which stops the render when printed. Add| default(0).
Totals of a calculation
Section titled “Totals of a calculation”sum adds one attribute. For quantity times price, add up in a loop with a namespace:
cat > net.txt <<'EOF'{% set ns = namespace(total=0) -%}{% for l in lines -%} {% set ns.total = ns.total + l.qty * l.price -%}{% endfor -%}{{ ns.total | currency("EUR") }}EOFcurl -F template=@net.txt \ -F 'data={"lines":[{"qty":2,"price":19.5},{"qty":3,"price":4}]}' \ http://localhost:9027/v1/render -o out.txtgrep -x 'EUR 51.00' out.txtCounts
Section titled “Counts”| Want | Write |
|---|---|
| Items | lines | length |
| Items that pass a test | lines | selectattr("qty", "gt", 1) | list | length |
| Items where a value is set and true | lines | selectattr("paid") | list | length |
| Distinct values | lines | map(attribute="group") | unique | list | length |
| Distinct values, listed | lines | map(attribute="group") | unique | join(", ") |
cat > counts.txt <<'EOF'{{ lines | length }} lines{{ lines | selectattr("qty", "gt", 1) | list | length }} with more than one{{ lines | map(attribute="group") | unique | list | length }} groups{{ lines | map(attribute="group") | unique | sort | join(", ") }}EOFcat > lines.json <<'EOF'{ "lines": [ {"group": "Home", "qty": 2}, {"group": "Food", "qty": 3}, {"group": "Food", "qty": 1} ]}EOFcurl -F template=@counts.txt -F data=@lines.json \ http://localhost:9027/v1/render -o out.txtcat > expected.txt <<'EOF'3 lines2 with more than one2 groupsFood, HomeEOFtest "$(cat out.txt)" = "$(cat expected.txt)"selectattr(attribute, test, value) keeps the items that pass a test. The comparison tests are eq, ne, lt, le, gt and ge, and in checks a value against a list. rejectattr keeps the others.
Filtered and per-group totals
Section titled “Filtered and per-group totals”Filter first, then add up:
{{ lines | selectattr("group", "eq", "Food") | sum(attribute="price") }}{{ lines | selectattr("group", "in", ["Food", "Drinks"]) | sum(attribute="price") }}To get a total for each group, groupby splits the list by an attribute. Each group has grouper, the shared value, and list, its items. Groups come out sorted by that value.
cat > groups.txt <<'EOF'{% for g in lines | groupby("group") -%} {% set total = g.list | sum(attribute="price") -%} {{ g.grouper }}: {{ g.list | length }} lines, {{ total }}{% endfor -%}{% set food = lines | selectattr("group", "eq", "Food") -%}Food only: {{ food | sum(attribute="price") }}EOFcat > lines.json <<'EOF'{ "lines": [ {"group": "Home", "price": 19.5}, {"group": "Food", "price": 4}, {"group": "Food", "price": 2.5} ]}EOFcurl -F template=@groups.txt -F data=@lines.json \ http://localhost:9027/v1/render -o out.txtcat > expected.txt <<'EOF'Food: 2 lines, 6.5Home: 1 lines, 19.5Food only: 6.5EOFtest "$(cat out.txt)" = "$(cat expected.txt)"In an Office template, put the group loop around a table and the line loop on a table row, as Office formats explains.
Running totals
Section titled “Running totals”A running total, or balance, grows from one line to the next. Two ways work.
Keep it in a namespace, which a loop can change:
{% set ns = namespace(balance=0) %}{% for t in moves -%} {% set ns.balance = ns.balance + t.amount -%} {{ t.label }} {{ ns.balance }}{%- endfor %}Or add up the lines so far, with a slice of the list. moves[:loop.index] is every line up to this one:
{% for t in moves -%} {{ t.label }} {{ moves[:loop.index] | sum(attribute="amount") }}{%- endfor %}The namespace is quicker on long lists, because the slice adds every earlier line again on each row.
cat > balance.txt <<'EOF'{% set ns = namespace(balance=100) -%}{% for t in moves -%} {% set ns.balance = ns.balance + t.amount -%} {% set so_far = moves[:loop.index] | sum(attribute="amount") -%} {{ t.label }} {{ t.amount }} {{ ns.balance }} {{ so_far }}{% endfor %}EOFcat > moves.json <<'EOF'{ "moves": [ {"label": "Rent", "amount": -80}, {"label": "Sale", "amount": 250}, {"label": "Fee", "amount": -5} ]}EOFcurl -F template=@balance.txt -F data=@moves.json \ http://localhost:9027/v1/render -o out.txtcat > expected.txt <<'EOF'Rent -80 20 -80Sale 250 270 170Fee -5 265 165EOFtest "$(cat out.txt)" = "$(cat expected.txt)"loop.previtem and loop.nextitem give the neighbouring items, to show a change from the line before: {{ t.amount - loop.previtem.amount if not loop.first }}.
Sorting
Section titled “Sorting”{{ lines | sort(attribute="price") }}{{ lines | sort(attribute="price", reverse=true) }}{{ lines | sort(attribute="group,price") }}A comma between attributes sorts by the first, then by the second within it. Text sorts without regard to case unless you add case_sensitive=true. To take the top three, sort and slice: (lines | sort(attribute="price", reverse=true))[:3].
cat > sorted.txt <<'EOF'{% set top = (lines | sort(attribute="price", reverse=true))[:2] -%}{{ lines | sort(attribute="group,price") | map(attribute="name") | join(", ") }}{{ top | map(attribute="name") | join(", ") }}EOFcat > lines.json <<'EOF'{ "lines": [ {"name": "Lamp", "group": "Home", "price": 19.5}, {"name": "Tea", "group": "Food", "price": 4}, {"name": "Rice", "group": "Food", "price": 2.5} ]}EOFcurl -F template=@sorted.txt -F data=@lines.json \ http://localhost:9027/v1/render -o out.txttest "$(cat out.txt)" = "$(printf 'Rice, Tea, Lamp\nLamp, Tea')"Lookups and joins between lists
Section titled “Lookups and joins between lists”When one list refers to another by an id, find the matching item with selectattr and first:
cat > join.txt <<'EOF'{% for o in orders -%} {% set c = customers | selectattr("id", "eq", o.customer) | first -%} {{ o.number }}: {{ c.name if c is defined else "unknown customer" }}{% endfor %}EOFcat > orders.json <<'EOF'{ "customers": [ {"id": 7, "name": "Ada"}, {"id": 9, "name": "Grace"} ], "orders": [ {"number": "A-1", "customer": 9}, {"number": "A-2", "customer": 7}, {"number": "A-3", "customer": 4} ]}EOFcurl -F template=@join.txt -F data=@orders.json \ http://localhost:9027/v1/render -o out.txtcat > expected.txt <<'EOF'A-1: GraceA-2: AdaA-3: unknown customerEOFtest "$(cat out.txt)" = "$(cat expected.txt)"first of an empty list is undefined, so test it with is defined before reading from it.
If the data can carry an object keyed by id instead of a list, the lookup is direct: with customers as {"7": {"name": "Ada"}}, write {{ customers[o.customer | string].name }}. JSON keys are always text, so a number id needs | string.
The first and last items are items[0] and items[-1], or items | first and items | last.
Store values with set and namespace
Section titled “Store values with set and namespace”{% set %} names a value, so you calculate it once:
{% set net = lines | sum(attribute="price") %}{% set tax = (net * 0.16) | round(2) %}Net {{ net }}, tax {{ tax }}, total {{ net + tax }}{% set name %}...{% endset %} captures the text between the tags.
A set inside a loop lasts only for that pass of the loop. A value set before the loop is unchanged after it:
cat > scope.txt <<'EOF'{% set count = 0 -%}{% set ns = namespace(count=0) -%}{% for i in items -%} {% set count = count + 1 -%} {% set ns.count = ns.count + 1 -%}{% endfor -%}{{ count }} {{ ns.count }}EOFcurl -F template=@scope.txt -F 'data={"items":[1,2,3]}' \ http://localhost:9027/v1/render -o out.txtgrep -x '0 3' out.txtTo carry a value out of a loop, use namespace() and set its attributes, as the second counter does. A namespace can hold several values: namespace(total=0, count=0, last=none).
A macro is a calculation you name and reuse:
{% macro net(l) %}{{ (l.qty * l.price) | round(2) }}{% endmacro %}{{ net(lines[0]) }}A macro returns text. To use its result in arithmetic, {{ net(l) | float }} turns it back into a number.
Number helpers
Section titled “Number helpers”| Filter | Example | Prints |
|---|---|---|
avg, avg(attribute=...) |
{{ [1, 2, 6] | avg }} |
3 |
ceil, floor |
{{ 2.1 | ceil }} |
3 |
words |
{{ 1234 | words }} |
one thousand two hundred thirty-four |
words(ordinal=true) |
{{ 21 | words(ordinal=true) }} |
twenty-first |
words(currency="EUR") |
{{ 12.5 | words(currency="EUR") }} |
twelve euros and fifty cents |
words spells a number in English, French or Ukrainian, following the template’s lang setting. Amounts on cheques and contracts use it:
cat > words.txt <<'EOF'{{ total | currency("EUR") }} ({{ total | words(currency="EUR") }}){{ rank | words(ordinal=true) }}EOFcurl -F template=@words.txt -F 'data={"total":1250.5,"rank":3}' \ http://localhost:9027/v1/render -o out.txtcat > expected.txt <<'EOF'EUR 1,250.50 (one thousand two hundred fifty euros and fifty cents)thirdEOFtest "$(cat out.txt)" = "$(cat expected.txt)"Dates in data are text such as 2026-11-06 or 2026-11-06T10:20:00Z, or unix seconds. These filters calculate with them, and return text that the date filter can print in any format.
| Filter | Example | Gives |
|---|---|---|
date_add(amount, unit) |
{{ "2026-01-31" | date_add(1, "month") }} |
2026-02-28 |
date_sub(amount, unit) |
{{ "2026-03-31" | date_sub(1, "month") }} |
2026-02-28 |
start_of(unit) |
{{ "2026-11-06" | start_of("month") }} |
2026-11-01 |
end_of(unit) |
{{ "2026-11-06" | end_of("quarter") }} |
2026-12-31 |
date_diff(other, unit) |
{{ "2026-12-06" | date_diff("2026-11-06", "days") }} |
30 |
now() |
{{ now() }} |
2026-11-06T10:20:00Z |
The units are year, quarter, month, week, day, hour, minute and second, in the singular or the plural. Without a unit, date_add, date_sub and date_diff count days.
cat > dates.txt <<'EOF'{{ issued | date_add(30) }}{{ issued | date_add(1, "month") }}{{ issued | end_of("month") }}{{ due | date_diff(issued) }}{{ issued | date_add(36, "hours") }}EOFcurl -F template=@dates.txt \ -F 'data={"issued":"2028-01-31","due":"2028-03-01"}' \ http://localhost:9027/v1/render -o out.txtcat > expected.txt <<'EOF'2028-03-012028-02-292028-01-31302028-02-01T12:00:00EOFtest "$(cat out.txt)" = "$(cat expected.txt)"What each one does:
- Months and years keep the day of the month when the target month has it, and use the month’s last day when it does not: 31 January plus one month is 28 February, or 29 in a leap year. A quarter is three months.
- A date stays a date.
2026-11-06plus a day is2026-11-07. Adding hours, minutes or seconds to a date gives a time:2026-11-07T12:00:00. - A time keeps its zone.
2026-11-06T22:00:00+03:00plus 4 hours is2026-11-07T02:00:00+03:00. Zones are fixed offsets, so there is no daylight saving to skip or repeat. Unix seconds come back as UTC text, such as2026-11-06T10:20:00Z. start_ofandend_ofgive the first and last second of the period:start_of("day")is midnight andend_of("day")is23:59:59. A week runs from Monday to Sunday. For a date with no time,end_of("month")is the month’s last day.date_diffcounts whole units from the argument to the value, so it is negative when the value is earlier. Part of a unit is dropped: 23 hours is0days. Months follow the same rule asdate_add, so 31 January to 29 February 2028 is one month. A value with no zone counts as UTC when compared with one that has a zone.
A result outside the years chrono can hold, about 262,000 years either side of year 0, is refused with the date is out of range. A unit that does not exist is refused with the list of units.
Today’s date
Section titled “Today’s date”now() gives the time of the render in UTC, to the second, such as 2026-11-06T10:20:00Z. Pass it to other filters: {{ now() | date_add(14) | date("%d %B %Y") }}.
Output that uses now() changes on every run, which breaks comparisons with a saved file. Send ctx.now to fix it for one request:
cat > today.txt <<'EOF'Printed {{ now() }}valid until {{ now() | date_add(14) }}{{ due | date_diff(now()) }} days leftEOFcurl -F template=@today.txt -F 'data={"due":"2026-12-01"}' \ -F 'ctx={"now":"2026-11-06T10:20:00Z"}' \ http://localhost:9027/v1/render -o out.txtcat > expected.txt <<'EOF'Printed 2026-11-06T10:20:00Zvalid until 2026-11-20T10:20:00Z24 days leftEOFtest "$(cat out.txt)" = "$(cat expected.txt)"ctx.now accepts anything date accepts. A date with no time, such as 2026-11-06, makes now() a date too. A value that cannot be read is an error naming ctx.now. As with any ctx key, a ctx object inside data wins.
Format the result
Section titled “Format the result”In an Office template, print a calculated date with date:
mkdir t && cd t && unzip -q ../report.docxexport TAG='Pay by {{ issued | date_add(30) | date("%d %B %Y") }},'TAG+=' {{ issued | date_diff(start, "weeks") }} weeks in.'perl -pi -e 's{A document with no tags in it.}{$ENV{TAG}}' word/document.xmlzip -qr ../dated.docx . && cd ..
curl -F template=@dated.docx \ -F 'data={"issued":"2026-11-06","start":"2026-09-01"}' \ http://localhost:9027/v1/render -o out.docxunzip -p out.docx word/document.xml | grep -o 'Pay by 06 December 2026, 9 weeks in.'Durations
Section titled “Durations”duration prints a length of time given in milliseconds, or as an ISO 8601 duration such as P1DT2H30M. With no argument it gives words, and with a unit it gives a number:
| Value | Tag | Prints |
|---|---|---|
7200000 |
duration |
2 hours |
30000 |
duration |
a few seconds |
5400000 |
duration("hours") |
1.5 |
"PT1H30M" |
duration("minutes") |
90 |
"P2W" |
duration("days") |
14 |
cat > took.txt <<'EOF'{{ ms | duration }}{{ ms | duration("minutes") }}{{ iso | duration }}{{ iso | duration("hours") | round(1) }}EOFcurl -F template=@took.txt -F 'data={"ms":5400000,"iso":"P1DT12H"}' \ http://localhost:9027/v1/render -o out.txttest "$(cat out.txt)" = "$(printf '2 hours\n90\n2 days\n36')"The units are years, months, weeks, days, hours, minutes, seconds and milliseconds (or ms). In an ISO duration, and for the months and years units, a month counts 30 days and a year 365. The words are in English, rounded the way people say them: 5 minutes, an hour, 3 days, a month, 2 years. The sign is ignored, so -7200000 is also 2 hours.
To print the time between two dates, take the difference in a unit, {{ end | date_diff(start, "minutes") }} minutes, or turn seconds into milliseconds for duration: {{ ((end | date_diff(start, "seconds")) * 1000) | duration }}.
Worked example: an invoice
Section titled “Worked example: an invoice”This invoice groups its lines, shows a subtotal for each group and a running balance on each line, adds tax, and prints the due date and the amount in words. It is a text template so the whole output fits on this page. The same tags work in a DOCX, with the line loop on a table row.
cat > invoice.txt <<'EOF'{%- set ns = namespace(balance=0) -%}{%- set due = issued | date_add(30) -%}Invoice {{ number | pad_left(6, "0") }}, issued {{ issued }}, due {{ due }}{% for g in lines | groupby("group") %}{{ g.grouper | upper }}{%- set sub = namespace(total=0) %}{%- for l in g.list %}{%- set amount = l.qty * l.price %}{%- set sub.total = sub.total + amount %}{%- set ns.balance = ns.balance + amount %}{%- set name = l.name | pad_right(14) %}{%- set qty = l.qty | pad_left(4) %}{%- set price = "%7.2f" | format(l.price) %}{%- set cost = "%9.2f" | format(amount) %}{%- set balance = "%9.2f" | format(ns.balance) %} {{ name }}{{ qty }} x {{ price }} {{ cost }} {{ balance }}{%- endfor %} {{ "Subtotal" | pad_right(29) }}{{ "%9.2f" | format(sub.total) }}{% endfor %}{%- set tax = (ns.balance * vat) | round(2) %}{%- set total = ns.balance + tax %}Net {{ ns.balance | currency("KES") }}VAT {{ tax | currency("KES") }} ({{ (vat * 100) | round | int }}%)Total {{ total | currency("KES") }}{{ total | words(currency="DOLLAR") | replace("dollar", "shilling") | capitalize }}.{% if paid is not empty -%} {% set paid_total = paid | sum(attribute="amount") | currency("KES") -%} Paid {{ paid_total }} on {{ paid | map(attribute="date") | join(", ") }}.{%- endif %}EOFcat > invoice-data.json <<'EOF'{ "number": 42, "issued": "2026-11-06", "vat": 0.16, "lines": [ {"group": "Design", "name": "Logo", "qty": 1, "price": 800}, {"group": "Printing", "name": "Flyers", "qty": 500, "price": 0.4}, {"group": "Design", "name": "Cards", "qty": 2, "price": 150}, {"group": "Printing", "name": "Posters", "qty": 20, "price": 3.5} ], "paid": [{"date": "2026-11-10", "amount": 500}]}EOFcurl -F template=@invoice.txt -F "data=<invoice-data.json" \ http://localhost:9027/v1/render -o out.txtcat > expected.txt <<'EOF'Invoice 000042, issued 2026-11-06, due 2026-12-06
DESIGN Logo 1 x 800.00 800.00 800.00 Cards 2 x 150.00 300.00 1100.00 Subtotal 1100.00
PRINTING Flyers 500 x 0.40 200.00 1300.00 Posters 20 x 3.50 70.00 1370.00 Subtotal 270.00
Net KES 1,370.00VAT KES 219.20 (16%)Total KES 1,589.20One thousand five hundred eighty-nine shillings and twenty cents.Paid KES 500.00 on 2026-11-10.EOFtest "$(cat out.txt)" = "$(cat expected.txt)"Each part of it:
namespace(balance=0)holds the running balance across both loops, andsubholds each group’s subtotal.groupby("group")sorts the groups, so Design comes before Printing whatever the order of the lines."%9.2f" | format(x)pads numbers to line up;pad_leftandpad_rightdo the same for text.words(currency="DOLLAR")givesdollarsandcents, whichreplaceturns into shillings, since Kenyan shillings are not one of the currencieswordsknows.{%-and-%}remove the line breaks the statements would leave in a text template.