Recipe

Export a multi-product dashboard to one PDF

Pressing every export button gives the reader six files and no report. Composing one document means knowing which of the seven products hands back a picture, and which two hand back nothing.

Four products collected into a single downloadable PDFOpen in new tab

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:

ProductReturns something you can embedDownload onlyNative PDF
ApexCharts 7.6.1dataURI({ scale }) resolves { imgURI }; getSvgString(scale) resolves a stringexportToPng(), exportToSVG()no
ApexMaps 0.4.0dataURI() resolves { imgURI }; getSvgString() returns a stringexportPNG(), exportSVG()no
ApexStock 0.5.1export({ format }) resolves { blob, url, text?, fallback? }download: trueyes
ApexGantt 3.18.1nothingexportChart('svg' | 'png' | 'pdf') resolves voidyes, download only
ApexTree 2.1.1nothinggraph.exportToSvg() returns undefinedno
ApexSankey 1.12.2nothinggraph.exportToSvg() returns undefinedno
ApexGrid 3.5.0no image export at allexportToCSV(), 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 SVGSerialize to canvasEmbed as vector in the PDF
A plain SVG with no foreignObjectrendersrenders
ApexSankey (draws real <text>)rendersrenders, text and all
ApexCharts, ApexTree (contain foreignObject)SecurityErrorrenders, 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:

PanelRouteBecause
Charts, mapsPNG, via the library's own dataURI()It rasterizes its own foreignObject internally, which a hand-rolled canvas pipeline cannot
SankeyVector, from the serialized DOM SVGReal <text>, so it arrives as selectable text
GridA table drawn from rowsNo image export exists at any tier, and a picture of a table cannot be searched
TreeAttach the SVG separately, or send the hierarchy as a tableBoth 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:

sourceRows
'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 from

See 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.

Get started