---
title: "Your own Renderer"
description: "You can write a Renderer for any drawing library."
canonical_url: "https://tiptap-docs.lin52025i.qzz.io/diagrams/renderers/custom"
---

# Your own Renderer

You can write a Renderer for any drawing library.

## A chart Renderer

This Renderer draws bar charts as plain SVG, without a library:

```ts
import type { ChartRenderer, ChartRenderInput } from '@tiptap-pro/extension-diagrams'

const SVG = 'http://www.w3.org/2000/svg'

function escapeXml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

function barsSvg({ data, spec, theme, size }: ChartRenderInput): string {
  const series = data.columns.flatMap((column, index) =>
    column.role === 'series' ? [{ ...column, index }] : [],
  )
  const colors = spec.palette ?? theme.series
  const values = data.rows
    .flatMap((row) => series.map((column) => row[column.index]))
    .filter((cell): cell is number => typeof cell === 'number')
  const max = Math.max(1, ...values)
  const band = size.width / Math.max(1, data.rows.length)
  const bar = band / (series.length + 1)
  const plot = size.height - 24

  const bars = data.rows.flatMap((row, rowIndex) => [
    ...series.map((column, seriesIndex) => {
      const value = typeof row[column.index] === 'number' ? (row[column.index] as number) : 0
      const height = (value / max) * plot
      const x = rowIndex * band + bar * (seriesIndex + 0.5)
      return `<rect x="${x}" y="${plot - height}" width="${bar}" height="${height}" fill="${colors[seriesIndex % colors.length]}"/>`
    }),
    `<text x="${rowIndex * band + band / 2}" y="${size.height - 6}" text-anchor="middle" fill="${theme.labelColor}" font-family="${escapeXml(theme.fontFamily)}" font-size="${theme.fontSize}">${escapeXml(String(row[0] ?? ''))}</text>`,
  ])

  return `<svg xmlns="${SVG}" width="${size.width}" height="${size.height}" viewBox="0 0 ${size.width} ${size.height}">${bars.join('')}</svg>`
}

export const plainBarsRenderer: ChartRenderer = {
  id: 'plain-bars',
  label: 'Plain bars',
  types: ['bar'],
  // Leaves stacked bars to the next Renderer that draws bar charts
  draws: ({ spec }) => !spec.stacked,
  mount(target, input) {
    const draw = (next: ChartRenderInput) => {
      const svg = new DOMParser().parseFromString(barsSvg(next), 'image/svg+xml').documentElement
      target.replaceChildren(target.ownerDocument.importNode(svg, true))
    }
    draw(input)
    return { update: draw, destroy: () => target.replaceChildren() }
  },
  async toSVG(input) {
    return barsSvg(input)
  },
}
```

### Registering it

```ts
import { Diagrams } from '@tiptap-pro/extension-diagrams'
import { echartsRenderer } from '@tiptap-pro/extension-diagrams/echarts'

Diagrams.configure({
  renderers: [echartsRenderer(), plainBarsRenderer],
})

editor.commands.setChartRenderer({ renderer: 'plain-bars' })
```

A chart that names `plain-bars` is drawn by it. Every other chart keeps its Renderer. See
[Which Renderer draws a chart](https://tiptap-docs.lin52025i.qzz.io/diagrams/renderers/overview.md#which-renderer-draws-a-chart).

### Members

| Member                  | Type                                                                 | Description                                                                                                                                                                            |
| ----------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                    | `string`                                                             | What a chart stores as its `renderer`                                                                                                                                                  |
| `label`                 | `string`                                                             | Optional. For a menu                                                                                                                                                                   |
| `types`                 | `readonly ChartType[]`                                               | The chart types it draws                                                                                                                                                               |
| `draws({ data, spec })` | `(chart) => boolean`                                                 | Optional. `false` for a chart of its types that it draws badly, so another Renderer takes it                                                                                           |
| `rules`                 | `RendererRules`                                                      | Optional. See [Rules of your Renderer](https://tiptap-docs.lin52025i.qzz.io/diagrams/renderers/custom.md#rules-of-your-renderer)                                                       |
| `mount(target, input)`  | `(target, input) => ChartRenderHandle \| Promise<ChartRenderHandle>` | Draws into `target`, an element inside the editor, and returns `{ update, destroy }`                                                                                                   |
| `toSVG(input)`          | `(input) => Promise<string>`                                         | Returns a standalone SVG string with concrete colors, used by `export`                                                                                                                 |
| `toImage(input)`        | `(input) => Promise<Blob>`                                           | Returns a PNG at `input.size`. For a library that draws on a canvas, see [A Renderer that draws on a canvas](https://tiptap-docs.lin52025i.qzz.io/diagrams/renderers/custom-canvas.md) |

A Renderer has either `toSVG` or `toImage`, and every exported file is made from what it
returns. The frame, the attribution and the size are the same either way.

- **With `toSVG`**, the type is `VectorChartRenderer`. The built-in Renderers are of this kind.
- **With `toImage`**, the type is `RasterChartRenderer`. An SVG file then holds the PNG, not
  shapes, and `export` returns `raster: true`.

`mount` returns `{ update, destroy }`. The extension calls `update(input)` with new input when
the data, the spec, the theme or the size changes. `destroy()` removes what `mount` added.

### What a Renderer receives

`input` is a `ChartRenderInput`:

| Field     | Type                | Description                                                                     |
| --------- | ------------------- | ------------------------------------------------------------------------------- |
| `data`    | `ChartData`         | The Chart Data, with only the columns the spec selected                         |
| `spec`    | `ChartSpec`         | The Chart Spec                                                                  |
| `theme`   | `ChartTheme`        | Concrete values to draw with, listed below                                      |
| `size`    | `{ width, height }` | In pixels                                                                       |
| `animate` | `boolean`           | `true` when the chart may move as it is drawn and changed. A file never gets it |

`theme` holds the `series` colors, `background`, `axisColor`, `gridColor`, `labelColor`,
`titleColor`, `fontFamily`, `fontSize`, `titleSize`, `titleWeight`, `legendGap` and
`legendMarkerSize`. These are resolved values, also in a dark color scheme. Draw with them, not
with `var(…)`, so the chart matches the page and exports correctly. `toSVG` and `toImage`
receive the light colors, unless the caller asked for `scheme: 'dark'`.

`animate` is `true` in the editor when the [`animation`](https://tiptap-docs.lin52025i.qzz.io/diagrams/api-reference/options.md#animation) option is on
and the user's system does not ask for reduced motion.

A Renderer never reads the document, a table or a Data Source. Typed rows, a table the chart
follows and a snapshot of a URL all arrive as Chart Data in `input.data`. See
[Data](https://tiptap-docs.lin52025i.qzz.io/diagrams/data/overview.md).

### What a Renderer must not do

- Run code that comes from the document.
- Insert markup that comes from the document.
- Set text such as labels and titles as HTML. Text is data, so set it as text.

### What to honor in the spec

| Spec field           | What to draw                                                                                                                |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `type`               | Only the types in `types`. Return `false` from `draws` for a variant you cannot draw                                        |
| `palette`            | Use it instead of `theme.series`                                                                                            |
| `stacked`            | Stack the series of a bar or area chart                                                                                     |
| `orientation`        | `'horizontal'`: lay the bars of a bar chart on their side                                                                   |
| `axes`, `references` | Grid and axis lines, ticks at every `axes.y.step`, and lines and bands at values. Decline through `draws` if you cannot     |
| `range`              | `{ min, max }` of a gauge                                                                                                   |
| `legend`, `title`    | Show or hide the legend and the title                                                                                       |
| `series`             | Line style, width, marker and smoothing per series of a line or area chart. Decline through `draws` if you cannot draw them |
| `renderer[id]`       | Options a chart stores for your Renderer only, under your `id`                                                              |

These functions from `@tiptap-pro/extension-diagrams/core` work out the axes as the built-in
Renderers draw them:

| Function                                       | Returns                                                                                                                                                     |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hasAxisLines(type)`                           | `true` for line, bar, area, scatter and combo, the types whose axis lines `spec.axes` sets                                                                  |
| `hasRadarLines(type)`                          | `true` for radar, whose rings (`y.grid`) and spokes (`x.line`) `spec.axes` sets                                                                             |
| `valueExtent(series, stacked)`                 | The lowest and highest value the value axis must reach, 0 included                                                                                          |
| `valueAxisScale(spec, extent)`                 | `{ min, max, ticks, fixed }` of the value axis, with the step and the [reference lines](https://tiptap-docs.lin52025i.qzz.io/diagrams/charts/references.md) |
| `resolveAxisStrokes(spec, numericCategories?)` | The grid and axis lines as drawn, every field decided                                                                                                       |

### Text in the chart

A user can double click a text in a chart, such as its title, and rename it there. See
[Editing in the chart](https://tiptap-docs.lin52025i.qzz.io/diagrams/charts/editing.md). The ECharts and D3 Renderers support this. The
Mermaid Renderer does not, so a double click opens nothing there.

Your Renderer supports it when the handle that `mount` returns has `onTextDoubleClick`. That
function takes a listener and returns a function that removes it. Call the listener on a double
click, with a `ChartTextHit`:

| Field    | Type              | Description                                                                    |
| -------- | ----------------- | ------------------------------------------------------------------------------ |
| `target` | `ChartTextTarget` | Which text was hit, see below                                                  |
| `text`   | `string`          | The text as drawn                                                              |
| `box`    | `ChartTextBox`    | `x`, `y`, `width`, `height` in the page's pixels, from `getBoundingClientRect` |

| Target                               | Text                                                       |
| ------------------------------------ | ---------------------------------------------------------- |
| `{ kind: 'title' }`                  | The title                                                  |
| `{ kind: 'axis', axis: 'x' \| 'y' }` | An axis title. `x` is the category axis, also on its side  |
| `{ kind: 'series', column }`         | A series name, by the column id in `input.data`            |
| `{ kind: 'category', row }`          | A category label or a pie part, by the row in `input.data` |

```ts
mount(target, input) {
  const svg = draw(input)
  target.append(svg)
  const listeners = new Set<(hit: ChartTextHit) => void>()
  svg.addEventListener('dblclick', event => {
    const title = event.target instanceof Element ? event.target.closest('.title') : null
    if (!title) return
    const { x, y, width, height } = title.getBoundingClientRect()
    const hit: ChartTextHit = {
      target: { kind: 'title' },
      text: title.textContent ?? '',
      box: { x, y, width, height },
    }
    listeners.forEach(listener => listener(hit))
  })
  return {
    update: next => {},
    destroy: () => svg.remove(),
    onTextDoubleClick: listener => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
}
```

The editor opens no text field in a read-only editor, and none for a series or a category of
read-only data.

## Rules of your Renderer

A library's license may ask for a visible credit, or limit the file formats you export to. A
Renderer declares this in `rules`, and the extension applies them wherever its charts or
diagrams appear. A Diagram Renderer takes the same `rules`, as the
[BPMN Renderer](https://tiptap-docs.lin52025i.qzz.io/diagrams/renderers/bpmn.md#the-bpmnio-attribution) does.

```ts
export const creditedRenderer: ChartRenderer = {
  ...plainBarsRenderer,
  id: 'credited-bars',
  rules: {
    attribution: {
      label: 'Drawn with Example',
      href: 'https://example.com',
      logo: '<svg viewBox="0 0 16 16">…</svg>',
    },
    exports: ['svg', 'png', 'docx'],
  },
}
```

| Rule          | Type                                                        | Description                                                      |
| ------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
| `attribution` | `{ label: string, href: string, logo?: string }`            | A credit shown with every chart or diagram, described below      |
| `exports`     | `readonly ('svg' \| 'png' \| 'webp' \| 'jpeg' \| 'docx')[]` | The formats `export` may produce. Without the list, every format |

### What attribution does

- **In the editor**, it is a link below the chart or diagram (`tiptap-chart-attribution`,
  `tiptap-flow-diagram-attribution`).
- **In every exported SVG, PNG, WebP, JPEG and Word image**, its label is drawn into the lower
  right corner.
- **`logo`** is standalone SVG markup, shown 16 pixels high.

### What exports does

- **A format that is not in the list** makes `export` throw.
- **Without `docx`**, `exportDocx` leaves a diagram out with a note, and writes a chart as a
  native Word chart.

### Adding rules

Rules you add can only make a Renderer stricter. `withRendererRules(renderer, rules)` returns the
Renderer with rules added. The `rendererRules` option adds rules by Renderer id.

```ts
import { Diagrams, withRendererRules } from '@tiptap-pro/extension-diagrams'

Diagrams.configure({
  renderers: [withRendererRules(plainBarsRenderer, { exports: ['svg', 'png'] })],
  rendererRules: {
    drawio: { attribution: { label: 'draw.io', href: 'https://www.drawio.com' } },
  },
})
```

- **An attribution the Renderer declares** stays. You cannot remove it.
- **An attribution you add** applies only where the Renderer declares none.
- **The allowed formats** are those both lists allow.
- **`editor.storage.diagrams.rulesFor(renderer)`** returns the rules in effect.
- **The built-in Renderers** for ECharts, D3, Mermaid and draw\.io declare no attribution and
  allow every format.
