Revenue report template with a chart
A revenue report answers whether this year is ahead of the last. A reader sees that faster in a chart than in a column of figures, so this one puts both on the page. Four headline figures sit at the top, the two years stand as bars month by month, and the table underneath gives the change per month. The chart isn’t a picture someone pasted in. Your code draws it from the same rows the table lists, every time the report renders. This is a ready-made quario revenue report — copy it, point it at your ledger, and render to PDF or HTML.
The template
The whole report is a definition — data you can version and review. The headline figures, the chart’s place on the page, the table and the totals are all in here. This is the exact file the preview below renders:
{
"data": "$.sales.months[*]",
"aggregates": {
"revenue": "sum:[email protected]",
"previous": "sum:[email protected]",
"orders": "sum:[email protected]",
"best": "max:[email protected]"
},
"style": { "family": "sans", "size": 10 },
"header": [
{
"type": "split",
"slots": [
{ "type": "text", "value": "Revenue report, {{ $.input.sales.year }}", "width": 60,
"style": { "family": "serif", "size": 19, "bold": true, "color": "#1e1f22" } },
{ "type": "text", "value": "{{ $.input.sales.period }} {{ $.input.sales.year }}",
"style": { "size": 8, "bold": false, "color": "#989a97", "align": "right" } }
]
},
{
"type": "split",
"slots": [
{ "type": "text", "value": "{{ $.input.sales.company }}", "width": 60,
"style": { "size": 8, "bold": false, "color": "#989a97" } },
{ "type": "text", "value": [{ "value": "Prepared " }, { "value": "{{ $.input.sales.prepared }}" }],
"style": { "size": 8, "bold": false, "color": "#989a97", "align": "right", "format": "date" } }
]
},
{
"type": "split",
"style": { "spaceBefore": 28 },
"slots": [
{ "type": "text", "value": "Revenue", "width": 25,
"style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97" } },
{ "type": "text", "value": "Against {{ $.input.sales.year - 1 }}", "width": 25,
"style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97" } },
{ "type": "text", "value": "Orders", "width": 25,
"style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97" } },
{ "type": "text", "value": "Best month",
"style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97" } }
]
},
{
"type": "split",
"style": { "spaceAfter": 22 },
"slots": [
{ "type": "text", "value": "{{ round($.revenue, 2) }}", "width": 25,
"style": { "family": "serif", "size": 16, "bold": false, "color": "#1e1f22", "format": { "kind": "currency", "digits": 0 } } },
{ "type": "text", "value": "{{ round($.revenue / $.previous - 1, 3) }}", "width": 25,
"style": { "family": "serif", "size": 16, "bold": false, "color": "#1e1f22", "format": { "kind": "percent", "digits": 1 } } },
{ "type": "text", "value": "{{ $.orders }}", "width": 25,
"style": { "family": "serif", "size": 16, "bold": false, "color": "#1e1f22" } },
{ "type": "text", "value": "{{ $.best }}",
"style": { "family": "serif", "size": 16, "bold": false, "color": "#1e1f22", "format": { "kind": "currency", "digits": 0 } } }
]
},
{
"type": "image",
"source": "=chart($.input.sales.months, $.input.sales.year)",
"fit": "width",
"alt": "Revenue per month, {{ $.input.sales.year }} beside {{ $.input.sales.year - 1 }}",
"style": { "spaceAfter": 28 }
}
],
"detail": {
"header": {
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97",
"borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" }
},
"row": { "style": { "color": "#5e6062" } },
"columns": [
{ "header": "Month", "value": "{{ @.month }}", "width": 28 },
{ "header": { "value": "Orders", "style": { "align": "right" } }, "value": "{{ @.orders }}", "width": 12, "style": { "align": "right" } },
{ "header": { "value": "{{ $.input.sales.year - 1 }}", "style": { "align": "right" } }, "value": "{{ @.previous }}", "width": 20, "style": { "align": "right", "format": "currency" } },
{ "header": { "value": "{{ $.input.sales.year }}", "style": { "align": "right" } }, "value": "{{ @.revenue }}", "width": 20, "style": { "align": "right", "color": "#1e1f22", "format": "currency" } },
{ "header": { "value": "Change", "style": { "align": "right" } }, "value": "{{ round(@.revenue / @.previous - 1, 3) }}", "width": 20, "style": { "align": "right", "format": { "kind": "percent", "digits": 1 } } }
],
"total": {
"style": { "bold": true, "color": "#1e1f22", "align": "right", "paddingTop": 4,
"borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
"rows": [
{
"cells": [
{ "value": "Total", "style": { "align": "left" } },
{ "value": "{{ $.orders }}" },
{ "value": "{{ round($.previous, 2) }}", "style": { "format": "currency" } },
{ "value": "{{ round($.revenue, 2) }}", "style": { "format": "currency" } },
{ "value": "{{ round($.revenue / $.previous - 1, 3) }}", "style": { "format": { "kind": "percent", "digits": 1 } } }
]
}
]
}
},
"footer": [
{ "type": "text", "value": "Amounts exclude VAT. Source: {{ $.input.sales.source }}.",
"style": { "size": 8, "color": "#989a97", "spaceBefore": 10 } }
],
"page": {
"footer": [
{
"type": "split",
"style": { "paddingTop": 10,
"borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
"slots": [
{ "type": "text", "value": "{{ $.input.sales.company }} · internal", "width": 70,
"style": { "size": 8, "color": "#989a97" } },
{ "type": "text", "value": "Page {{ page.number }} of {{ page.total }}",
"style": { "size": 8, "color": "#989a97", "align": "right" } }
]
}
]
}
}The headline figures are report aggregates — three sums and a max — read from $ in the
header. The change against last year is one expression,
round($.revenue / $.previous - 1, 3). The percent kind reads that ratio and prints 10.6%, with
"digits": 1 for the one decimal. The round keeps the stored ratio to the precision it presents,
so a workbook cell holds 0.106 rather than a float’s tail.
The chart is an image item whose source is =chart($.input.sales.months, $.input.sales.year).
chart is a function your code registers. The definition says where the chart goes and which rows
it draws, so the chart and the table can’t disagree.
The chart
The function is ordinary host code. d3’s scales place the months and the two bars inside each one, and the rest is a string of SVG. It needs no DOM, so it runs in Node, in a worker, or in the browser, which is where the preview below runs it:
import { scaleBand, scaleLinear } from "d3-scale";
type Month = { month: string; revenue: number; previous: number };
const THIS_YEAR = "#2e708f";
const LAST_YEAR = "#bfd0da";
const money = new Intl.NumberFormat("en-IE", {
style: "currency",
currency: "EUR",
notation: "compact",
});
/** This year's revenue beside last year's, per month, as the SVG's bytes. */
export function chart(months: Month[], year: number): Uint8Array {
const width = 720;
const height = 330;
const left = 52;
const top = 34;
const bottom = height - 26;
const x = scaleBand(
months.map((m) => m.month),
[left, width],
).padding(0.28);
const pair = scaleBand(["previous", "revenue"], [0, x.bandwidth()]).padding(
0.08,
);
const y = scaleLinear(
[0, Math.max(...months.flatMap((m) => [m.revenue, m.previous]))],
[bottom, top],
).nice();
const gridlines = y.ticks(4).map(
(tick) => `
<line x1="${left}" x2="${width}" y1="${y(tick)}" y2="${y(tick)}" stroke="#e3e8eb" />
<text x="${left - 8}" y="${y(tick) + 4}" text-anchor="end">${money.format(tick)}</text>`,
);
const bars = months.map((m) => {
const at = x(m.month) ?? 0;
const bar = (key: "previous" | "revenue", fill: string) =>
`<rect x="${at + (pair(key) ?? 0)}" y="${y(m[key])}" width="${pair.bandwidth()}" height="${bottom - y(m[key])}" fill="${fill}" />`;
return `
${bar("previous", LAST_YEAR)}${bar("revenue", THIS_YEAR)}
<text x="${at + x.bandwidth() / 2}" y="${height - 6}" text-anchor="middle">${m.month.slice(0, 3)}</text>`;
});
const legend = `
<rect x="${width - 112}" y="4" width="10" height="10" fill="${THIS_YEAR}" />
<text x="${width - 96}" y="13">${year}</text>
<rect x="${width - 56}" y="4" width="10" height="10" fill="${LAST_YEAR}" />
<text x="${width - 40}" y="13">${year - 1}</text>`;
// A width and height on the root: the image item reads its size from the bytes.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"
font-family="Helvetica, Arial, sans-serif" font-size="11" fill="#627179">
${gridlines.join("")}
${bars.join("")}
${legend}
</svg>`;
return new TextEncoder().encode(svg);
}The root states its own width and height, because quario reads the picture’s size from the
bytes, and the function returns those bytes as a Uint8Array. The labels stay in Helvetica’s
Western European set, which is what the PDF target draws SVG text in.
The data
A definition renders nothing on its own — you hand it a document. Each month carries this year’s revenue and last year’s, so one row feeds a pair of bars and a line of the table:
{
"sales": {
"company": "Quire Press Ltd.",
"year": 2026,
"period": "January to September",
"prepared": "2026-10-02",
"source": "sales ledger, invoices issued in the period",
"months": [
{"month": "January", "orders": 41, "revenue": 18420, "previous": 16950.5},
{"month": "February", "orders": 38, "revenue": 17260.75, "previous": 17880},
{"month": "March", "orders": 52, "revenue": 24310, "previous": 20140.25},
{"month": "April", "orders": 47, "revenue": 21980.5, "previous": 19325},
{"month": "May", "orders": 55, "revenue": 26140, "previous": 21760},
{"month": "June", "orders": 44, "revenue": 20875.25, "previous": 22410.5},
{"month": "July", "orders": 36, "revenue": 16930, "previous": 15280},
{"month": "August", "orders": 31, "revenue": 14615.5, "previous": 14090.75},
{"month": "September", "orders": 58, "revenue": 28460, "previous": 23115}
]
}
}What it renders
This isn’t a screenshot. quario compiles the definition and data above, runs chart.ts in your
browser, and @quario/viewer paints the result right here. The PDF button hands you the same report
as real @quario/pdf bytes, where the chart is vector paths rather than pixels. There’s no Word
button on this one: a Word document shows an SVG only beside a raster copy, so @quario/docx
refuses it.
Use it
npm install quario @quario/pdf d3-scaleimport { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import revenue from './revenue-report.report.json' with { type: 'json' }
// `chart` is the function from chart.ts
const q = quario({ locale: 'en-IE', currency: 'EUR' }) // what `format` presents through
const report = q.report(revenue, { chart })
const data = await getSales('2026') // your ledger
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)
writeFileSync('revenue-report.pdf', bytes)The Charts in a PDF guide draws the same kind of chart with ECharts and Vega-Lite. It also shows which of them can be a registered function.
Make it yours
The chart is your code, so it’s whatever your reader asks for. A line for the trend is a d3-shape
line() path beside the bars. A target is one more <line> across the plot. Another kind of chart
is another function, registered under its own name, and the definition calls each where it belongs.
The definition diffs in a pull request as data, and the chart diffs beside it as code.
FAQ
Can I generate the report as a PDF?
Yes — @quario/pdf renders it straight to PDF bytes, with no browser anywhere, and draws the chart
as vector paths. Full example →
Can I export the report to Excel or Word?
Not with an SVG chart. A worksheet drawing holds a raster picture only, and Word shows an SVG only
beside a raster copy. So @quario/xlsx and @quario/docx refuse it, with a render error that names
the item. For those renders, register a chart that returns PNG bytes. The definition stays the
same, and every number in the workbook is still a number.
Does the chart work in HTML?
Yes. @quario/html writes it as an <img> with a data: URI. A browser runs no script in it and
loads nothing it names, so a chart never becomes markup on your page.
Where are the other templates?
Browse the template gallery → — summary reports, statements, invoices, and more.