---
title: "Charts in a PDF with JavaScript — quario"
description: "Put a d3, ECharts or Vega-Lite chart in a PDF report from JavaScript. Draw the chart as SVG, hand quario the bytes, and the PDF gets vector paths. No headless browser."
url: "https://getquario.com/guides/charts-in-pdf"
---

# 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:

```bash title="shell"
npm install quario @quario/pdf d3-scale
```

The 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:

```json title="revenue.report.json"
{
  "data": "$.months[*]",
  "aggregates": { "orders": "sum:=@.orders", "revenue": "sum:=@.revenue" },
  "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:

```ts title="chart.ts"
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:

```ts title="app.ts"
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:

```json title="revenue.data.json"
{
  "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}
  ]
}
```

*Output: the page shows this definition rendered with its sample data by `@quario/html` — [see it rendered](https://getquario.com/guides/charts-in-pdf).*

## 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:

```ts title="chart.ts"
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:

```ts title="app.ts"
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:

```json title="revenue.report.json"
{ "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 `width` and `height`, or a `viewBox`. 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 →](https://getquario.com/docs/diving-deeper/images-and-splits.md)

## 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 →](https://getquario.com/docs/diving-deeper/security.md)

### 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 →](https://getquario.com/pricing.md)

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Guides","item":"https://getquario.com/guides"},{"@type":"ListItem","position":3,"name":"Charts in a PDF","item":"https://getquario.com/guides/charts-in-pdf"}]}
```
