Export custom nodes to .docx

Available in Start planBetav0.26.1

One of the biggest advantages of the @tiptap-pro/extension-export-docx extension is the ability to define how custom nodes in your Tiptap schema should be rendered in DOCX.

This allows you to preserve application-specific content in the exported Word file.

Looking for the REST-compatible variant?

Functions can't be serialized over HTTP, so the function API on this page only works inside the editor extension. For a JSON-based equivalent that travels over REST and runs in both surfaces, see the Custom-nodes DSL.

Export custom nodes to .docx

When calling editor.exportDocx(), you can pass an array of custom node definitions in the ExportDocxOptions argument. Each definition specifies the node type and a render function.

Custom node conventions

Custom node converters must adhere to the underlying DOCX generation library's requirements. In practice, a custom converter function for DOCX should return one of the allowed DOCX elements for that node: a Paragraph class (or an array of Paragraph classes), a Table class, a TextRun class, an ExternalHyperlink class, or null if the node should be skipped in the output.

Define the custom node extension

For the sake of the example, suppose your editor has a custom node type hintbox (a callout-styled box). You can define how it should appear in the exported document.

Here's how the Hintbox extension's custom node might look like:

import { mergeAttributes, Node } from '@tiptap/core'

export interface ParagraphOptions {
  /**
   * The HTML attributes for a paragraph node.
   * @default {}
   * @example { class: 'foo' }
   */
  HTMLAttributes: Record<string, any>
}

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    hintbox: {
      /**
       * Set a hintbox
       * @example editor.commands.setHintbox()
       */
      setHintbox: () => ReturnType
      /**
       * Toggle a hintbox
       * @example editor.commands.toggleHintbox()
       */
      toggleHintbox: () => ReturnType
    }
  }
}

/**
 * This extension allows you to create hintboxes.
 * @see https://www.tiptap.dev/api/nodes/paragraph
 */
export const Hintbox = Node.create<ParagraphOptions>({
  name: 'hintbox',

  priority: 1000,

  addOptions() {
    return {
      HTMLAttributes: {
        style: 'padding: 20px; border: 1px solid #b8d8ff; border-radius: 5px; background-color: #e6f3ff;',
      },
    }
  },

  group: 'block',

  content: 'inline*',

  parseHTML() {
    return [{ tag: 'p' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },

  addCommands() {
    return {
      setHintbox:
        () =>
        ({ commands }) => {
          return commands.setNode(this.name)
        },
      toggleHintbox:
        () =>
        ({ commands }) => {
          return commands.toggleNode(this.name, 'paragraph')
        },
    }
  },

  addKeyboardShortcuts() {
    return {
      'Mod-Alt-h': () => this.editor.commands.toggleHintbox(),
    }
  },
})

Define the custom node render function

And we will define how the Hintbox custom node should be rendered in the exported document:

// Import the ExportDocx extension
import {
  convertTextNode,
  Docx,
  ExportDocx,
  lineHeightToDocx,
  pixelsToHalfPoints,
  pointsToTwips,
} from '@tiptap-pro/extension-export-docx'

const editor = new Editor({
  extensions: [
    // Other extensions ...
    ExportDocx.configure({
      onCompleteExport: (result) => {
        setIsLoading(false)
        const blob = new Blob([result], {
          type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        })
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = 'export.docx'
        a.click()
        URL.revokeObjectURL(url)
      },
      exportType: 'blob',
      customNodes: [
        {
          type: 'hintbox',
          render: (node) => {
            // Here we define how our custom Hintbox node should be rendered in the DOCX.
            // Per the documentation, we should return a Docx node
            // that's either a Paragraph, an array of Paragraphs, a Table, a TextRun, an ExternalHyperlink, or null.
            return new Docx.Paragraph({
              children: node.content.map((content) => convertTextNode(content)),
              style: 'Hintbox', // Here we apply our custom style to the Paragraph node.
            })
          },
        },
      ], // Custom nodes
      styleOverrides: {
        paragraphStyles: [
          // Here we define our custom styles for our custom Hintbox node.
          {
            id: 'Hintbox',
            name: 'Hintbox',
            basedOn: 'Normal',
            next: 'Normal',
            quickFormat: false,
            run: {
              font: 'Aptos Light',
              size: pixelsToHalfPoints(16),
            },
            paragraph: {
              spacing: {
                before: pointsToTwips(12),
                after: pointsToTwips(12),
                line: lineHeightToDocx(1),
              },
              border: {
                // DOCX colors are in Hexadecimal without the leading #
                top: { style: Docx.BorderStyle.SINGLE, size: 1, color: 'b8d8ff', space: 5 },
                bottom: { style: Docx.BorderStyle.SINGLE, size: 1, color: 'b8d8ff', space: 5 },
                right: { style: Docx.BorderStyle.SINGLE, size: 1, color: 'b8d8ff', space: 5 },
                left: { style: Docx.BorderStyle.SINGLE, size: 1, color: 'b8d8ff', space: 5 },
              },
              shading: {
                type: Docx.ShadingType.SOLID,
                color: 'e6f3ff',
              },
            },
          },
        ],
      }, // Style overrides
    }),
    // Other extensions ...
  ],
  // Other editor settings ...
})

Then, at a later point in your application, you can export the editor content to a DOCX file:

editor.chain().exportDocx().run()

You can construct any supported DOCX elements in the render function using the Docx library classes (Paragraph, TextRun, Table, etc.) that are provided via the Docx import from the @tiptap-pro/extension-export-docx package.

See Native DOCX options for RTL alignment, percentage widths, and default styles.

Fit images to the page or table cell

An image without width and height exports at its pixel size, so a large photo runs past the right margin in Word. A custom node that renders an image can keep it inside the page.

render gets a second argument, context. Its maxImageWidth is the width of the page's text column in pixels, or the content width of the table cell the node is in (the cell width minus its left and right margins). List indents are not subtracted. context is undefined for a custom node inside a customNodeDsl rule.

Pass it as maxWidth to convertImage or convertImageRun:

import { convertImage, ExportDocx } from '@tiptap-pro/extension-export-docx'

ExportDocx.configure({
  customNodes: [
    {
      type: 'photo',
      render: (node, context) => {
        const { src, width, height } = node.attrs ?? {}

        return convertImage({
          node: { type: 'image', attrs: { src, width, height } },
          maxWidth: context?.maxImageWidth,
        })
      },
    },
  ],
})

maxWidth only changes an image that has neither width nor height. A wider image shrinks to maxWidth and keeps its aspect ratio. An image with its own size, and imageOverrides.transformation, still win.

When the node lays out content around the image, use convertImageRunWithSize. It returns the image run together with the size it gets in the document ({ imageRun, width, height }, in pixels), or null for an image the export skipped. This example puts a caption under the image in a table as wide as the image:

import {
  convertImageRunWithSize,
  Docx,
  ExportDocx,
  pixelsToPoints,
  pointsToTwips,
} from '@tiptap-pro/extension-export-docx'

ExportDocx.configure({
  customNodes: [
    {
      type: 'figure',
      render: async (node, context) => {
        const { src, width, height } = node.attrs ?? {}
        const image = await convertImageRunWithSize({
          node: { type: 'image', attrs: { src, width, height } },
          maxWidth: context?.maxImageWidth,
        })
        if (!image) return null

        // Word measures table widths in twips.
        const tableWidth = Math.round(pointsToTwips(pixelsToPoints(image.width)))

        return new Docx.Table({
          // A fixed layout without cell margins keeps the table exactly as wide as the image.
          layout: Docx.TableLayoutType.FIXED,
          width: { size: tableWidth, type: Docx.WidthType.DXA },
          columnWidths: [tableWidth],
          margins: { top: 0, bottom: 0, left: 0, right: 0 },
          rows: [
            new Docx.TableRow({
              children: [
                new Docx.TableCell({
                  children: [
                    new Docx.Paragraph({ children: [image.imageRun] }),
                    new Docx.Paragraph({ text: node.attrs?.caption }),
                  ],
                }),
              ],
            }),
          ],
        })
      },
    },
  ],
})

Built-in image nodes are not affected: they keep exporting at their own size.