Charts in a PDF, with JavaScript
A report that runs to a table of figures often wants a chart above them. quario lays out text, tables and images, and a chart is none of those until you draw it. So you draw it. A chart library gives you an SVG, your code hands quario the bytes, and the PDF target draws the SVG as vector paths. There’s no headless browser and no screenshot, and the bars stay sharp at any zoom.
The approach in one line
Your chart library → SVG bytes → an image item. The definition says where the chart goes. Your code says what it looks like.
Do it with quario
Install quario, the PDF target, and a chart library. This page draws with d3’s scales:
npm install quario @quario/pdf d3-scaleThe definition places the chart as an image item in the report header, above the table it
summarises. It’s still JSON. Its source calls a function, chart, that your code registers:
{
"data": "$.months[*]",
"aggregates": { "orders": "sum:[email protected]", "revenue": "sum:[email protected]" },
"style": { "family": "sans", "size": 10 },
"header": [
{ "type": "text", "value": "Revenue by month", "style": { "family": "serif", "size": 19, "bold": true, "color": "#1e1f22" } },
{ "type": "text", "value": "{{ $.input.company }} · {{ $.input.period }}", "style": { "size": 8, "bold": false, "color": "#989a97", "spaceAfter": 20 } },
{ "type": "image", "source": "=chart($.input.months)", "fit": "width", "alt": "Revenue by month, {{ $.input.period }}", "style": { "spaceAfter": 20 } }
],
"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": 50 },
{ "header": { "value": "Orders", "style": { "align": "right" } }, "value": "{{ @.orders }}", "width": 20, "style": { "align": "right" } },
{ "header": { "value": "Revenue", "style": { "align": "right" } }, "value": "{{ @.revenue }}", "width": 30, "style": { "align": "right", "format": "currency" } }
],
"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($.revenue, 2) }}", "style": { "format": "currency" } }
]
}
]
}
}
}=chart($.input.months) hands the function the same months the table lists, so the chart and the
figures can’t disagree. fit: "width" scales the picture to the content width and keeps its aspect
ratio. alt is the text the HTML target writes beside it.
The function is ordinary host code. d3’s scales turn months and amounts into positions, and the rest is a string of SVG. Nothing here needs a DOM, so it runs in Node, in a worker, or at the edge:
import { scaleBand, scaleLinear } from "d3-scale";
const money = new Intl.NumberFormat("en-IE", {
style: "currency",
currency: "EUR",
notation: "compact",
});
/** Revenue per month as a bar chart, returned as the SVG's bytes. */
export function chart(
months: { month: string; revenue: number }[],
): Uint8Array {
const width = 640;
const height = 220;
const left = 48;
const bottom = height - 28;
const x = scaleBand(
months.map((m) => m.month),
[left, width],
).padding(0.35);
const y = scaleLinear(
[0, Math.max(...months.map((m) => m.revenue))],
[bottom, 12],
).nice();
const gridlines = y.ticks(4).map(
(tick) => `
<line x1="${left}" x2="${width}" y1="${y(tick)}" y2="${y(tick)}" stroke="#dfe7ec" />
<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;
return `
<rect x="${at}" y="${y(m.revenue)}" width="${x.bandwidth()}" height="${bottom - y(m.revenue)}" fill="#2e708f" />
<text x="${at + x.bandwidth() / 2}" y="${height - 8}" text-anchor="middle">${m.month.slice(0, 3)}</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="12" fill="#627179">
${gridlines.join("")}
${bars.join("")}
</svg>`;
return new TextEncoder().encode(svg);
}Two things in it are requirements rather than taste. The root states its own width and height,
because quario reads the picture’s size from the bytes. A percentage size fails the render. And the
return value is a Uint8Array holding the SVG as UTF-8 text, not the string. An image source is bytes
whatever the format.
Register the function and render:
import { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import definition from './revenue.report.json' with { type: 'json' }
// `chart` is the function from chart.ts
const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition, { chart })
const months = await getMonths() // your figures, from an API or DB
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), {
company: 'Quire Press Ltd.',
period: 'April to September 2026',
months,
})
writeFileSync('revenue.pdf', bytes)Here’s the sample data, and the definition rendered against it. This is @quario/html at the site’s
build, running the chart.ts above. The HTML target writes the SVG into an <img> as a data: URI,
and the PDF draws the same bytes as paths:
{
"company": "Quire Press Ltd.",
"period": "April to September 2026",
"months": [
{"month": "April", "orders": 14, "revenue": 9840.5},
{"month": "May", "orders": 19, "revenue": 12310},
{"month": "June", "orders": 11, "revenue": 7425.25},
{"month": "July", "orders": 17, "revenue": 11980},
{"month": "August", "orders": 9, "revenue": 6150.75},
{"month": "September", "orders": 22, "revenue": 15870}
]
}| Month | Orders | Revenue |
|---|---|---|
| April | 14 | €9,840.50 |
| May | 19 | €12,310.00 |
| June | 11 | €7,425.25 |
| July | 17 | €11,980.00 |
| August | 9 | €6,150.75 |
| September | 22 | €15,870.00 |
| Total | 92 | €63,576.50 |
With ECharts
ECharts renders to an SVG string on the server when you start it with ssr: true and no element.
The function keeps the same shape, so the definition doesn’t change:
import * as echarts from 'echarts'
export function chart(months: { month: string; revenue: number }[]): Uint8Array {
const view = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 640, height: 220 })
view.setOption({
animation: false,
grid: { left: 56, right: 0, top: 12, bottom: 28 },
xAxis: { type: 'category', data: months.map((m) => m.month.slice(0, 3)) },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: months.map((m) => m.revenue), itemStyle: { color: '#2e708f' } }],
})
const svg = view.renderToSVGString()
view.dispose() // a live chart keeps Node from exiting
return new TextEncoder().encode(svg)
}Set grid yourself. ECharts’ default margins leave a server-rendered plot squeezed into a strip of
its own height. And dispose() the chart once you have the string. A chart left open holds the
process.
With Vega-Lite
Vega renders headless to SVG too, but toSVG() returns a promise. quario reads an async function
from a cell’s value and nowhere else, so one can’t feed an image source. Draw the
chart before the render instead, and pass its bytes in with the data:
import * as vega from 'vega'
import { compile } from 'vega-lite'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import definition from './revenue.report.json' with { type: 'json' }
const months = await getMonths()
const { spec } = compile({
width: 560,
height: 180,
data: { values: months },
mark: { type: 'bar', color: '#2e708f' },
encoding: {
x: { field: 'month', type: 'ordinal', sort: null, title: null, axis: { labelAngle: 0 } },
y: { field: 'revenue', type: 'quantitative', title: null },
},
})
const svg = await new vega.View(vega.parse(spec), { renderer: 'none' }).toSVG()
const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), {
company: 'Quire Press Ltd.',
period: 'April to September 2026',
months,
chart: new TextEncoder().encode(svg),
})The definition’s image then reads the bytes rather than calling a function:
{ "type": "image", "source": "=$.input.chart", "fit": "width", "alt": "Revenue by month, {{ $.input.period }}" }Either way works with any library. A function suits a chart drawn per group or per row, since the definition decides where it’s called. Bytes in the data suit a chart drawn once, or one that comes from a service that already makes them.
What the PDF can draw
The PDF target draws the shapes an SVG declares itself: paths, rectangles, lines, circles, fills and strokes. Some things it doesn’t draw, and a chart library’s output has to live within them:
- Text is Helvetica. SVG
<text>draws in a base-14 sans face, whatever family it names, and a character outside that face’s set prints as?. Western European text, digits and€are fine. - No
<image>,<use>or<script>. Each draws nothing, and the render fetches nothing. A library that stamps its markers with<use>loses them. - A size on the root. Absolute
widthandheight, or aviewBox. The charts on this page all state one.
Workbooks and Word documents
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 an SVG with a render error that names the item, rather than
leaving a hole where the chart was. When the same definition also renders to a workbook or a Word
document, register a chart that returns PNG bytes for that render. The definition stays the same,
and only the registry changes. What each target does with an image →
FAQ
Does the chart stay vector in the PDF?
Yes. The PDF target draws the SVG as vector paths, so it prints and zooms sharp. Nothing turns into pixels on the way.
Can I use Chart.js?
Chart.js draws on a canvas, so it gives you pixels, not SVG. Encode the canvas as PNG and hand quario those bytes instead. Every target draws a PNG, so the workbook and Word take it too.
Is it safe to put an SVG in a report?
Yes. No target writes an SVG out as markup. The HTML target puts it in an <img>, where a browser
runs no script in it and loads nothing it names. The PDF target reads only its shapes. The
report renders without eval, and the chart is the one part of it that’s your code.
Security →
Can I use it for free?
Evaluating it’s free and has no time limit — the full engine, no key to request — and evaluation output carries a watermark. Anything past evaluating it, including shipping it in an application or running it in your business, needs a license for each production deployment. Its users, agents, and renders are never counted, and replicas of one service count once. See pricing →