Built with ApexCharts.js, ApexGrid, ApexMaps, ApexSankey
"Send me that as a PDF" is the last request a dashboard gets and the one it is least designed for. Each product on the screen has an export button, and pressing all of them gives your reader six files and no report.
Composing one document means collecting six pictures yourself, and the work is not the PDF library. It is that the seven products in this family do not hand back the same thing, and two of them hand back nothing at all.
npm install apexcharts apex-grid apexmaps jspdf
What does each product hand back?
This is the table to read before writing any of the pipeline, because it decides the shape of the whole thing. Checked against the installed versions, by calling each method and inspecting what came back:
| Product | Returns something you can embed | Download only | Native PDF |
|---|---|---|---|
| ApexCharts 7.6.1 | dataURI({ scale }) resolves { imgURI }; getSvgString(scale) resolves a string | exportToPng(), exportToSVG() | no |
| ApexMaps 0.4.0 | dataURI() resolves { imgURI }; getSvgString() returns a string | exportPNG(), exportSVG() | no |
| ApexStock 0.5.1 | export({ format }) resolves { blob, url, text?, fallback? } | download: true | yes |
| ApexGantt 3.18.1 | nothing | exportChart('svg' | 'png' | 'pdf') resolves void | yes, download only |
| ApexTree 2.1.1 | nothing | graph.exportToSvg() returns undefined | no |
| ApexSankey 1.12.2 | nothing | graph.exportToSvg() returns undefined | no |
| ApexGrid 3.5.0 | no image export at all | exportToCSV(), exportAs('csv') | no |
Three consequences, in the order they will bite.
The grid is not a picture. ApexGrid's export pipeline is data-only: CSV in the community build, XLSX in the enterprise one. There is no PNG and no SVG. A grid arrives in your PDF as a drawn table, from rows, or it does not arrive. That is not a limitation to work around, it is the correct answer, because a rasterized table is unsearchable and unselectable in a document whose whole purpose is to be read.
getSvgString is two different methods. On ApexCharts it returns a Promise.
On ApexMaps it returns the string. Same name, opposite contract, and the failure
is quiet in one direction: await on the map version is harmless, while calling
.length on the chart version without awaiting gives you undefined rather than
an error.
const chartSvg = await chart.exports.getSvgString(2) // Promise<string>
const mapSvg = map.getSvgString() // string, already
ApexTree and ApexSankey give you nothing. Both expose exportToSvg() on the
object render() returns, and both return undefined: they trigger a download
and that is the entire API. To put either in a composed document you read the
SVG out of the DOM yourself.
Reading an SVG out of the DOM
The workaround is four lines, and it is the same four for both products:
function svgFromDom(containerSelector) {
const svg = document.querySelector(`${containerSelector} svg`)
if (!svg) throw new Error(`no svg under ${containerSelector}`)
return new XMLSerializer().serializeToString(svg)
}
Serializing a rendered ApexSankey this way produces a well-formed SVG with the
xmlns attribute already on the root, so it is valid standalone. It carries
class attributes pointing at a stylesheet that is not inside the SVG, so
detached from the page that styling is gone. Inline what you need, or accept
the default shapes.
Do the same to an ApexTree and you get something that looks identical and behaves completely differently, for one reason.
The <foreignObject> wall
A <foreignObject> holds real HTML inside an SVG. It is how a library draws a
node from an HTML template, and it is the single thing that decides what you can
do with the resulting file.
Measured in Chromium, serializing each product's live SVG and pushing it down both routes:
| Source SVG | Serialize to canvas | Embed as vector in the PDF |
|---|---|---|
A plain SVG with no foreignObject | renders | renders |
ApexSankey (draws real <text>) | renders | renders, text and all |
ApexCharts, ApexTree (contain foreignObject) | SecurityError | renders, minus the HTML |
Two results there are worth more than the rest.
The canvas route does not fail politely. Drawing a foreignObject SVG into
a canvas taints it, so the next getImageData or toDataURL throws
SecurityError rather than handing back a blank image. Your export button
throws instead of producing a bad PDF, which is the better of the two failures.
The vector route fails silently, which is the worse one. svg2pdf does not
throw on a foreignObject. It drops the content and writes the rest, so you get
a PDF with an empty rectangle where the labels were and no error anywhere. I
checked by searching the uncompressed PDF for the label text: present for
ApexSankey, absent for ApexTree.
So ApexTree is the hard case, and it is worth saying plainly: its node content
is entirely foreignObject, with no <text> in the output at all, so neither
route carries its labels. A tree in a composed PDF is a picture of boxes.
That leaves the per-panel decision:
| Panel | Route | Because |
|---|---|---|
| Charts, maps | PNG, via the library's own dataURI() | It rasterizes its own foreignObject internally, which a hand-rolled canvas pipeline cannot |
| Sankey | Vector, from the serialized DOM SVG | Real <text>, so it arrives as selectable text |
| Grid | A table drawn from rows | No image export exists at any tier, and a picture of a table cannot be searched |
| Tree | Attach the SVG separately, or send the hierarchy as a table | Both embed routes lose the labels; a browser opening the .svg does not |
The general rule underneath it: use each library's own exporter before you
reach for a generic one. ApexCharts output taints a canvas if you serialize it
yourself, yet chart.exports.dataURI() returns a perfectly good PNG from the
same chart, because the library handles the part the generic path cannot.
ApexStock says the same thing out loud, resolving its export with
fallback: true when it had to return SVG instead of the PNG you asked for.
Collecting the panels
One collector per panel, all resolving to the same shape, so the document builder never learns which product produced what:
// Every collector resolves to { kind, payload, title }.
async function collectPanels({ chart, map, grid, rows }) {
const [chartImg, mapImg] = await Promise.all([
chart.exports.dataURI({ scale: 2 }),
// 1.5, not 2: world geometry is the heaviest thing in the document, and
// that one step costs megabytes for detail nobody reads at panel size.
map.dataURI({ scale: 1.5 }),
])
return [
{ kind: 'image', title: 'Revenue by quarter', payload: chartImg.imgURI },
{ kind: 'image', title: 'Revenue by region', payload: mapImg.imgURI },
{ kind: 'svg', title: 'Pipeline flow', payload: svgFromDom('#sankey') },
{
kind: 'table',
title: 'Accounts',
payload: {
head: grid.columns.filter((c) => !c.hidden).map((c) => c.headerText ?? c.key),
body: rows.map((r) => grid.columns.filter((c) => !c.hidden).map((c) => r[c.key])),
},
},
]
}
Scale above 1 on both raster calls: a PDF is printed or zoomed, and a chart captured at CSS pixel density is visibly soft on paper. ApexMaps defaults its own scale to 2 for exactly this reason and also paints the container's background colour behind the image, so a dark-mode map arrives dark rather than as pale strokes on transparency. Watch the map though, because it is the one panel where the choice is expensive: the same four-panel report measured 4.8 MB with the map at 2 and 3.4 MB at 1.5, and the difference is world-border detail at the size of a postcard.
Note what the table collector reads: grid.columns, filtered to the visible
ones, and your own rows. You are rebuilding the grid's presentation, which is
why the next section matters more than it looks.
Which rows should the table contain?
The grid's own export already answers this and its answer is better than the one
you would invent. ExportSource takes four values and the default is the one you
want:
source | Rows |
|---|---|
'view' (default) | post-filter, post-sort, across all pages |
'all' | the raw data array you supplied |
'page' | the current page slice only |
'selected' | the current selection |
'view' is right for a report because it matches what the reader was looking at
when they asked for the PDF, including their filter, and unlike 'page' it does
not truncate at the page boundary. If you are drawing the table yourself rather
than exporting CSV, reproduce that rule: filtered and sorted, not paginated.
Exporting the data alongside the document is worth doing anyway, because it is the one panel a reader may want to do arithmetic on:
grid.exportToCSV({ filename: 'accounts', source: 'view' })
Composing the document
The instinct is to loop the panels and stack them down the page. Do that and the report stops looking like the thing the reader asked you to export: a dashboard they read as a 2x2 grid arrives as one long column.
Two rules make it match. Lay the panels out in the same grid the screen uses, and let every panel state its own height instead of being forced into one.
const PAGE_W = 595.28 // A4, points
const PAGE_H = 841.89
const MARGIN = 40
const GUTTER = 16
const COL_W = (PAGE_W - MARGIN * 2 - GUTTER) / 2
const TITLE_H = 15
// How tall this panel wants to be at a given width. Forcing a fixed height
// here is what squashes a wide map into a tall box and pads it with white.
function panelHeight(doc, panel, w) {
if (panel.kind === 'image') {
// jsPDF reads the PNG header, so the aspect comes from the file rather
// than from a number you guessed.
const props = doc.getImageProperties(panel.payload)
return w * (props.height / props.width)
}
if (panel.kind === 'svg') {
const { vw, vh } = svgAspect(panel.payload) // viewBox first, then width/height
return w * (vh / vw)
}
return TABLE_HEAD_H + panel.payload.body.length * TABLE_ROW_H
}
async function buildReport(panels) {
const doc = new jsPDF({ unit: 'pt', format: 'a4' })
doc.setFontSize(18)
doc.text('Q3 report', MARGIN, MARGIN + 8)
let y = MARGIN + 30
for (let i = 0; i < panels.length; i += 2) {
const row = panels.slice(i, i + 2)
// The row is as tall as its tallest panel, so the next row starts level
// even when a map and a table disagree about height.
const rowH = Math.max(...row.map((p) => TITLE_H + panelHeight(doc, p, COL_W)))
if (y + rowH > PAGE_H - MARGIN) {
doc.addPage()
y = MARGIN
}
for (let c = 0; c < row.length; c++) {
await drawPanel(doc, row[c], MARGIN + c * (COL_W + GUTTER), y, COL_W)
}
y += rowH + 22
}
doc.save('dashboard.pdf')
}
drawPanel is the only place that branches on panel kind, and each branch is
one call: addImage for a raster, doc.svg for a vector, your own renderer for
the table.
async function drawPanel(doc, panel, x, y, w) {
doc.setFontSize(11)
doc.text(panel.title, x, y + 10)
const top = y + TITLE_H
const h = panelHeight(doc, panel, w)
if (panel.kind === 'image') {
doc.addImage(panel.payload, 'PNG', x, top, w, h)
} else if (panel.kind === 'svg') {
// Only send SVG here that draws real <text>: anything carrying a
// foreignObject arrives as an empty rectangle.
const el = new DOMParser().parseFromString(panel.payload, 'image/svg+xml').documentElement
await doc.svg(el, { x, y: top, width: w, height: h })
} else {
drawTable(doc, panel.payload, x, top, w)
}
return TITLE_H + h
}
doc.svg() comes from svg2pdf.js, a companion jsPDF loads if it is present.
If you would rather not add the dependency, keep the SVG panels as a separate
.svg attachment rather than forcing them through canvas, since that is the
route that throws.
One detail worth copying in the table renderer: give it the grid's own column
widths rather than dividing the space evenly. grid.columns already carries
them, so the columns land where the reader saw them, and at half page width you
want the text clipped to its cell rather than running into the next one.
What breaks first
A chart that is not finished rendering. dataURI() captures the DOM as it
stands. Call it in the same tick as render() and you get a correctly sized,
entirely empty image, with no error anywhere. Await the render and let the
animation settle, or turn animation off for the export path:
await chart.render()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
const { imgURI } = await chart.exports.dataURI({ scale: 2 })
Fonts. A serialized SVG references font families by name and does not carry
the font. Whatever renders it substitutes, so a chart whose labels fit exactly in
the browser can overlap in the PDF. Charts and maps exported through dataURI()
are immune, because rasterization happens in the browser that has the font. The
SVG panels are not, which is one more reason to keep raster and vector panels
separate rather than standardising on one.
A virtualized Gantt. exportChart() expands the full dataset to take its
snapshot and restores virtualization afterwards. It is correct, and on a large
project it is slow and memory-hungry at exactly the moment the user is waiting.
Export it on its own rather than inside Promise.all with five other panels.
Assuming the PNG you asked for is the PNG you got. ApexStock's export
resolves with fallback: true when the browser blocked raster capture and it
returned SVG instead. The property exists so you can branch on it; ignoring it
means handing addImage an SVG string:
const out = await stock.export({ format: 'png', scale: 2 })
if (out.fallback) {
// It is SVG now, whatever we asked for.
panels.push({ kind: 'svg', title: 'Price', payload: await out.blob.text() })
} else {
panels.push({ kind: 'image', title: 'Price', payload: out.url })
}
When to use something else
If the report is one chart, do not build a pipeline. ApexStock composes a PDF on its own, including a text summary of the visible range, and ApexGantt exports directly to PDF too. One call each:
await stock.export({ format: 'pdf', include: ['analysis'], download: true })
await gantt.exportChart('pdf')
If the report runs on a schedule rather than on a button, move it off the browser entirely. Everything here depends on a rendered DOM, which on a server means driving a headless browser. At that point ApexCharts' server-side rendering path gives you the SVG without a browser at all, and the composition step stays the same.
And if the reader's real need is to work with the numbers, send the CSV. A PDF of a table is a picture of data; the export the grid already ships is the data.
Which plan covers this?
ApexCharts.js, ApexGrid and ApexMaps are included on every plan, including
Community, which is free for organizations under $2M USD in annual revenue.
dataURI(), getSvgString() and the grid's CSV export are not gated features.
ApexTree and ApexSankey are included from the Pro plan upward. ApexGantt and ApexStock, including its PDF export, are Premium and up. Every one of them renders in full without a licence key, watermarked, so you can build and test the pipeline before deciding which products the report needs.
The three-product dashboard these panels are collected fromSee the pieces running
Reference documentation
Frequently Asked Questions
How do I export an ApexCharts chart as an image without downloading it?
Call chart.exports.dataURI({ scale: 2 }), which resolves to an object carrying imgURI, a PNG data URI you can hand straight to a PDF library. chart.exports.getSvgString(scale) resolves to the SVG markup instead. The exportToPng and exportToSVG methods trigger a browser download and return nothing, so they are the wrong half of the API for a composed report.
Can I export an ApexGrid as an image?
No. The grid has no image export at any tier: its pipeline is data-only, CSV in the community build and XLSX in the enterprise one. In a composed PDF a grid should be drawn as a real table from its rows anyway, because a rasterized table cannot be searched or selected in the finished document.
Why does my exported org chart come out blank?
Because ApexTree draws its node content as HTML inside a foreignObject, and neither export route carries that. Serializing the SVG and drawing it to a canvas taints the canvas, so the next getImageData or toDataURL throws a SecurityError. Embedding the same SVG as vector through svg2pdf does not throw at all: it drops the foreignObject and writes the rest, leaving an empty rectangle where the labels were. ApexSankey is not affected, because it draws real SVG text. For a tree, attach the .svg separately or send the hierarchy as a table.
Why is my exported chart image empty?
Because dataURI captures the DOM as it currently stands, and calling it in the same tick as render gives a correctly sized empty image with no error. Await the render and let a couple of animation frames pass first, or disable animation on the export path.
Which rows should the table in the report contain?
The grid's own export defaults to the source called view, meaning post-filter and post-sort rows across all pages, and that is the right rule for a report because it matches what the reader was looking at when they asked for it. The page source truncates at the page boundary and all ignores their filter. If you draw the table yourself rather than exporting CSV, reproduce the view rule: filtered and sorted, not paginated.
Related
See the export API running
The ApexMaps export demo covers scale, background and the difference between downloading and embedding.