Charts
json-to-office gives you two ways to put charts in a document: native charts (the chart component) and Highcharts-rendered images (the highcharts component). Both formats have both. Two caveats on the native one: the docx chart needs renderer: "office-open", because docx.js has no chart primitive, and bubble is drawn only by the pptx pptxgenjs renderer. This page helps you choose between them and get each one running.
Native charts vs Highcharts
chart (native) | highcharts | |
|---|---|---|
| Formats | pptx (both renderers, bubble on pptxgenjs only); docx with renderer: "office-open" | pptx + docx, every renderer |
| Output | Real PowerPoint / Word chart object | PNG image |
| Editable by recipients | Yes — data and styling editable in the Office app | No — it's a picture |
| Scaling | Vector, crisp at any zoom | Raster (use scale for sharper exports) |
| External dependencies | None | Requires a running Highcharts Export Server |
| Works in the browser | Yes | No — Node-only (needs server-side fetch) |
| Chart catalog | pptx 9 types: area, bar, bar3D, bubble, doughnut, line, pie, radar, scatter — bubble on pptxgenjs only; docx 8: the same minus bar3D and bubble, plus column | The full Highcharts catalog: heatmaps, treemaps, gauges, combined series, annotations, … |
| Theme integration | Palette + text colors follow the theme automatically | Theme palette injected when options.colors unset |
Rule of thumb: reach for native chart first. It needs no infrastructure, recipients can tweak it, and it covers the common business-chart types. Reach for highcharts when you need chart types or styling an Office chart can't express — or when you're generating a Word document on the default docxjs renderer, where highcharts is the only chart component.
Why the docx chart is renderer-scoped
docx.js has no chart primitive at all, so the component is absent from that renderer's schema rather than accepted and dropped. @office-open/docx does have one, though it writes only the cached values: json-to-office splices in the embedded workbook, the series colors and the axis titles afterwards, which is what makes Edit Data work and the theme palette apply. See the component reference.
The pptx office-open renderer needs the same repair: the same pass writes the workbook there too, so both pptx backends draw an editable native chart. @office-open/pptx forwards more of the chart options than its docx sibling, so only the cell references and the series colors have to be spliced in.
One exception: the native bubble chart is pptxgenjs-only, and is absent from the docx component altogether. @office-open spells a bubble series as x/y/size triples rather than categories and values, and there is no unambiguous reading of a category label as a numeric x — so it is refused by name rather than guessed at. A Highcharts bubble chart is unaffected: highcharts keeps its full catalog on every renderer.
Theme palette
Both paths default their series colors to the theme palette, so charts recolor themselves when you switch themes. The same token list, in the same order — primary, secondary, accent, accent4, accent5, accent6 — backs the native chart in both formats and highcharts in both formats, and both theme schemas declare all six names (accent4–accent6 optional in each). A theme that fills all six with hex values therefore paints the same series colors in a deck and in a document, letter case included.
Everywhere, an explicit options.colors (or chartColors on the native chart) always wins: when you set it, nothing is injected and no fallback warning is emitted.
Slots the theme leaves unset
Both formats do the same thing: the missing slot is dropped. The injected palette is only as long as the theme has tokens defined, the chart library reuses that shorter list once the series outnumber it (Highcharts wraps back to the start), and no warning is emitted — an implicit palette shrinking to fit the theme is normal, not a mistake.
Dropping compacts, it does not leave a gap. A theme that defines accent5 but not accent4 produces primary, secondary, accent, accent5, so accent5 paints the fourth series. The token list is a preference-ordered pool of candidate colors, not fixed per-series slots, so a color you did define is kept rather than discarded.
Built-in themes differ across the two formats
All four built-in PPTX themes (consulting, default, dark, minimal) define accent4–accent6, so a pptx chart gets six distinct colors out of the box; consulting additionally orders its series through palette.chart.
None of the five built-in DOCX themes define them, so a docx chart palette is primary, secondary, accent today — three colors, wrapping from the fourth series on. To get six, add accent4, accent5 and accent6 to a custom DOCX theme; the schema accepts them.
Skipping applies to the implicit palette only. Naming an unset token explicitly — chartColors: ["accent4"] on the native pptx chart, or any pptx color prop pointing at an unfilled slot — still falls back to primary and emits a THEME_COLOR_FALLBACK warning. That is an authoring error and stays loud.
Tokens that point at other tokens
Both palettes resolve each token through the theme's own recursive name resolution, so "accent4": "primary" is a valid chart color in either format and picks up whatever primary holds. A value that reaches no color — a typo'd token name, or a reference cycle — is skipped like an unset slot rather than handed to the renderer verbatim. That matters because neither renderer complains: the Highcharts export server and PowerPoint both answer an unparseable color by drawing the series black.
Named explicitly, an unresolvable token stays loud in both formats, with different mechanics. On pptx it falls back to primary and emits a warning — THEME_COLOR_FALLBACK when the slot is unset, UNKNOWN_COLOR when the slot holds a value that resolves to nothing. Docx has no warning channel for colors: it throws.
Only DOCX themes can spell the reference
The two theme schemas differ on the color value. A docx theme color is #RRGGBB or the name of another color; a pptx theme color must be strict hex (^#?[0-9A-Fa-f]{6}$), so "accent4": "primary" fails jto pptx validate. A pptx reference chain therefore only reaches generation through a theme the schema never sees — a customThemes object, a --theme-path file (parsed as plain JSON, without validation), or an inline props.theme in a run with validation.enabled = false — an inline theme is part of the document, so the generation gate checks it against ThemeConfigSchema like any other prop.
Quick examples
Native pptx chart
No server needed — this renders anywhere:
{
"name": "chart",
"props": {
"type": "line",
"data": [
{
"name": "Users",
"labels": ["Jan", "Feb", "Mar", "Apr"],
"values": [120, 180, 260, 390]
}
],
"title": "Monthly active users",
"showTitle": true,
"showLegend": false,
"lineSmooth": true,
"grid": { "column": 0, "row": 1, "columnSpan": 12, "rowSpan": 4 }
}
}Every series needs both labels and values; pie and doughnut charts take a single series. The full option set (axes, legend, data labels, bar/line/pie specifics) is in the pptx charts reference.
pptx highcharts
The options object is a standard Highcharts configuration, and it must include chart.width and chart.height in pixels — the export server needs fixed dimensions to render. On the slide, position with x/y/w/h (inches or %) or grid; when omitted, size defaults to the chart's pixel dimensions at 96 px/inch.
{
"name": "highcharts",
"props": {
"options": {
"chart": { "type": "column", "width": 800, "height": 500 },
"title": { "text": "Revenue by region" },
"xAxis": { "categories": ["EMEA", "APAC", "AMER"] },
"series": [
{ "name": "2025", "data": [1.2, 0.8, 2.1] },
{ "name": "2026", "data": [1.4, 1.1, 2.6] }
]
},
"grid": { "column": 1, "row": 1, "columnSpan": 10, "rowSpan": 4 }
}
}docx highcharts
The same component exists for Word documents. Instead of slide coordinates it takes optional width/height (pixels, or a "90%" string relative to content width) for the rendered image size. It lives inside a section like any other docx block:
{
"name": "docx",
"props": { "theme": "default" },
"children": [
{
"name": "section",
"children": [
{ "name": "heading", "props": { "text": "Results", "level": 2 } },
{
"name": "highcharts",
"props": {
"options": {
"chart": { "type": "pie", "width": 600, "height": 400 },
"series": [
{
"name": "Share",
"data": [
{ "name": "Product A", "y": 61 },
{ "name": "Product B", "y": 39 }
]
}
]
},
"width": "80%"
}
}
]
}
]
}See DOCX components for the docx-side props.
Running the export server locally
The highcharts component talks to a Highcharts Export Server over HTTP. For local development, run one with pnpm dlx:
pnpm dlx highcharts-export-server --enableServer trueIt listens on http://localhost:7801 by default — which is exactly where json-to-office looks when no server URL is configured. Start the server, generate your document, done:
pnpm dlx highcharts-export-server --enableServer true &
pnpm dlx @json-to-office/jto pptx generate report.pptx.json -o report.pptxTIP
The export server uses Puppeteer/Chromium under the hood, so the first run downloads a browser. If a document with highcharts components fails with a connection error, the message will remind you of the command above.
What leaves the process
Nothing is drawn locally: json-to-office POSTs one JSON body per highcharts component to the export server's /export endpoint and embeds the PNG it gets back. That body is:
| Field | Contents |
|---|---|
infile | The complete options object as authored — every series with every data point, category labels, titles, axis titles, tooltips — plus the theme palette written into colors and the theme typography into the text styles |
resources | Your resources prop, with the registered non-safe theme families inlined as @font-face CSS (the font bytes, base64) ahead of it |
type, b64, scale | Export format (png, base64) and the raster scale |
Nothing else about the document goes: not its text, its other components, its metadata or its theme file. A chart (native, office-open) or a visual sends nothing anywhere.
Because the body carries the data, where it goes is opt-in. An export server the address itself proves private — localhost, a loopback, RFC 1918 or link-local address, a unique-local IPv6 address, or a .local, .internal or .home.arpa name — needs nothing. A hostname that DNS decides (charts, charts.corp) is not guessed at: it takes the same switch as a public one. Any other URL is refused at generation time until you set services.highcharts.allowRemote: true (CLI, playground and MCP: HIGHCHARTS_ALLOW_REMOTE=1), and once allowed every generation reports W_HIGHCHARTS_REMOTE_EXPORT once per export server it reached, naming the URL that received the chart data — in the generation warnings, and as a warning diagnostic from jto_generate. A server that is down is a failed generation, not a skipped figure: the document is not produced without its chart.
Pointing at a deployed server
For CI or production you'll usually run the export server as a service — the project's own render server bundles a Highcharts Export Server behind an /export endpoint, ready to deploy.
The server URL resolves per component in this order:
serverUrlprop on the individualhighchartscomponentservices.highcharts.serverUrlfrom generation options / environment- Default
http://localhost:7801
Library
Pass a services config to any generation call:
import { generateAndSaveFromJson } from '@json-to-office/json-to-pptx';
await generateAndSaveFromJson(document, 'report.pptx', {
services: {
highcharts: {
serverUrl: 'https://charts.example.com',
},
},
});The same services option exists on @json-to-office/json-to-docx generation calls and on the plugin-system generators.
CLI and playground
The CLI and playground read the server URL from the environment:
export HIGHCHARTS_SERVER_URL=https://charts.example.com
export HIGHCHARTS_ALLOW_REMOTE=1 # a public hostname: say so, or generation refuses it
pnpm dlx @json-to-office/jto pptx generate report.pptx.json -o report.pptxAuthentication headers
If your export server sits behind an API key or gateway, attach headers via services.highcharts.headers. Headers can be a static object or an async function that receives the request body (for signed requests):
await generateAndSaveFromJson(document, 'report.pptx', {
services: {
highcharts: {
serverUrl: 'https://charts.example.com',
headers: { 'x-api-key': process.env.CHARTS_API_KEY! },
// or computed per request:
// headers: async (body) => ({ Authorization: `Bearer ${await getToken(body)}` }),
},
},
});On the CLI, set HIGHCHARTS_API_KEY (and optionally HIGHCHARTS_API_KEY_HEADER, default x-api-key) and the header is attached for you:
export HIGHCHARTS_SERVER_URL=https://charts.example.com
export HIGHCHARTS_API_KEY=sk-...How hard the server is pushed
One chart is one Puppeteer render, and a self-hosted export server usually runs a single worker. A document's charts are therefore queued rather than posted all at once: at most four requests are in flight per server URL, and the cap is process-wide, so several documents generated together in one process share it instead of each opening a pool of their own. A request the server could not answer — a timeout, a refused connection, a 429 or a 5xx — is retried twice with jittered exponential backoff; a 4xx is about the chart you sent and fails at once.
| Option | Default | What it does |
|---|---|---|
services.highcharts.concurrency | 4 | Requests in flight at once against this export server |
services.highcharts.timeoutMs | 30000 | Abort one export request after this long |
services.highcharts.retries | 2 | Retries after the first attempt, for a failure the server may fix |
await generateAndSaveFromJson(document, 'report.docx', {
services: {
highcharts: {
serverUrl: 'https://charts.example.com',
// A server with more workers can take more at once; a slow one that
// renders large charts needs longer before an abort is honest.
concurrency: 8,
timeoutMs: 60000,
},
},
});A chart identical to one already rendered in the same document is requested once and its PNG reused, so a figure that appears in a summary and again in its own section costs one render. Identical means the same export server and the same resolved request body — the options after the theme's palette and typography have been written into them. Where the component sits on the page is not part of that: two appearances at different positions share one render. The width it is placed at usually is, because the type in a chart is sized for it — the same series shrunk into half the measure is drawn at a different scale, so it is a different PNG.
getChartRequestStats() (from @json-to-office/core-docx or @json-to-office/core-pptx) reports the cumulative counters behind all of this — collected charts, unique requests after dedupe, retries spent, and maxInFlight, the most requests open against an export server at one time. maxInFlight sitting at the cap for a whole run says the queue, not the renderer, is what the documents are waiting on; retries climbing says the server is shedding work. resetChartRequestStats() zeroes them.
Theme typography in Highcharts output
The chart is a PNG drawn by a browser that has never seen the document, so on its own it would come out in the export server's default face at Highcharts' own sizes — visibly foreign to the prose around it. json-to-office therefore writes the document's typography into the request, beneath whatever the author set:
| Highcharts option | Value |
|---|---|
chart.style.fontFamily | the theme's body family, quoted, with the generic it belongs to ("Calibri", sans-serif; "Georgia", serif) |
title.style | the heading family at the heading3 size and weight, in the primary text colour |
subtitle, axis labels and title | the label size in the secondary text colour |
legend.itemStyle, plotOptions.series.dataLabels | the label size in the primary text colour, with the chartLabel role's weight when the theme states one |
caption, credits.style | the source size in the secondary text colour |
credits.enabled | false — the highcharts.com credit never reaches a document unless the author sets credits.enabled: true |
The label size is the theme's chartLabel type role and the source size its source role when the theme declares them (shared visual layers); otherwise labels sit one point under the body size in docx and two under the body style in pptx, and the source one point under that (the caption size in pptx).
Sizes are written in chart pixels scaled by the width the image is placed at, so a label that must read as 9pt on the page is drawn larger in a 900px chart that is shrunk into a 450pt measure. In docx the placement follows the component's width (pixels, or a percentage of the content width); in pptx it follows w (inches, or a percentage of the slide). A chart placed by neither reads as 96 px per inch.
Every explicit author value wins, property by property: setting title.style.fontSize keeps the theme's family and colour on the title, and setting chart.style.fontFamily keeps the theme's sizes. The family itself has to exist where the export server runs — a local server draws with the fonts installed on your machine; for a registered non-safe family, the document's own font bytes are inlined as @font-face rules ahead of any resources.css you supply (data URIs; they travel only to the export server, which already receives every data point). Safe fonts are never inlined.
Custom fonts in Highcharts output
The resources prop is forwarded to the export server, which lets you inject @font-face CSS (plus JS or extra files) yourself — for a family that is neither installed on the server nor registered in the document, or to override what the theme injects:
{
"name": "highcharts",
"props": {
"options": {
"chart": { "width": 800, "height": 500 },
"series": [{ "data": [1, 2, 3] }]
},
"resources": {
"css": "@font-face { font-family: 'Inter'; src: url('https://fonts.example.com/inter.woff2'); } .highcharts-root { font-family: 'Inter', sans-serif; }"
}
}
}Next steps
- PPTX charts reference — every prop on
chartandhighcharts - Render server — deploy the combined export/rasterize service
- Themes & styling — how the chart palette follows the theme
- DOCX components — the docx
highchartsandvisualcomponents