Skip to main content

Building SVG diagrams in Docusaurus

These docs support C4 Diagrams, which are <svg> files generated from React components. This page describes how these diagrams are constructed.

Boxes

The <C4Box> component draws the following:

Box Title[Type] Description, which can be multiplelines of text that need todynamically resize the box height.
<C4Box title="Box Title" type="Type" existing="true">
Description, which can be multiple lines of text that need to dynamically resize the box height.
</C4Box>

SVG, unlike HTML, doesn't automatically layout boxes based on their content.

The SVG is composed of a background rect:

And text lines drawn on top of it:

Box Title

However, text doesn't automatically wrap with SVG. Instead, we need to manually wrap the text into multiple lines.

What about in SVG2?

SVG2 adds support for auto-wrapped text, but browser support for inline-size is non-existent.

[Figure: SVG 2 user agent support for new text features]

There's also SVG Tiny 1.2, which added the <textArea> element, but browsers don't support that either.

Even if it was possible to auto-wrap text, we still need to know the height of the text to determine the box height. Unlike HTML <div>, the contents of the box won't automatically resize the rect, since each element's transform can only be determined by its parents in the hierarchy.

Alternatives considered

Determining the box height

To properly layout the box, we must:

  • Determine how the text splits into lines.
  • Use the number of lines to calculate the total height of the box.

Canvas method

Font metrics are required to split the text, and the easiest way to get those is to use the Canvas API:

Live Editor
Result
Loading...

However, Docusaurus is a static site generator, and the Canvas API is only available in the browser. There are plugins to implement Canvas within the Node.js backend, however they are excessive considering that we only need to measure text:

  • canvas requires Cairo, which depends on several other libraries which are complicated to build.
  • @napi-rs/canvas has no system dependencies, but it comes with a large binary size, 9 MB! This is mostly due to Skia, which is very powerful but that power comes at the cost of binary size.

Neither of these libraries work with Docusaurus out of the box, but even if we had a configuration where we used one of these libraries as a polyfill on the backend, and used the Canvas API in the browser, we still have another problem: fonts.

Server-side rendering

Docusaurus evaluates the React hierachy on the server-side, and produces static HTML pages. On the client-side, the React hierarchy is re-evaluated, and it's important for the first client-side render to produce the exact same DOM as server-side rendering (see Docusaurus documentation on Static site generation).

Docusaurus doesn't know what font will be used on the client side, and Docusaurus 2.1.0's theme specifies a long list of system-specific fonts to choose from:

--ifm-font-family-base: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';

However, text layout doesn't need to be perfect, as long as it meets the requirements:

  • The box height is large enough to fit the text.
  • The server-side DOM is identical to the initial client-side DOM.

If we can estimate the number of lines of text required, and the line height, that should be sufficient.

Server-side font metrics

Most Node.js libraries for font metrics use the same Canvas-based approach, so they cannot be used on the server-side. The fontkit library is an exception, since it loads the fonts itself, but it is 6 MB unpacked so it may have some impact on page load time. We need to calculate the number of lines using the same method on both the server and the client side to ensure the DOM is identical.

For now, fontkit is used, but it may be replaced with a custom implementation in the future. Here is a demo of the text size as measured between the browser and fontkit.

This is some sample text.

Pretty close! Surprisingly enough the font metrics originate from a completely different font, the Inter font family. This font was chosen since it's open source and is nearly a drop-in replacement for the default macOS font, SF Pro.

The downside of the current approach is that fontkit is also used on the client side, which requires downloading the fontkit library and font to the browser. Looking at build outputs:

1.2M    build/assets/js
2.3M build.new/assets/js

So about 1.1 MB of data may be downloaded to the browser. A better approach that reduces the download size impact should be considered in the future.

To compute the metrics, the Inter-Regular.woff2 file is used since it had a slightly smaller binary size (97K vs 131K for .woff).

Fontkit provides fewer APIs in the browser than in node.js, only the Buffer-based fontkit.create API is available. To load a buffer, use the webpack binary-loader extension:

font_measure.js
var fontkit = require('fontkit');
import fontData from '!!binary-loader!./../../static/font/Inter-Regular.woff2';

function toBuffer(binaryDataStr) {
let array = new Uint8Array(binaryDataStr.length);
for (let i = 0; i < binaryDataStr.length; ++i) {
array[i] = binaryDataStr.charCodeAt(i);
}

return array;
}

// Cache since font creation is slow.
const font = fontkit.create(toBuffer(fontData));

/**
* Measure the font metrics of the given text using the Inter Regular font.
*
* @param {string} text to measure
* @param {number} fontSize in pixels
* @returns {{width: number, ascent: number, descent: number, lineSpacing: number}} dict containing
* font metrics, all numbers positive
*/
export function measureInterText(text, fontSize) {
const glyphRun = font.layout(text);

const scale = fontSize / font.unitsPerEm;
const ascent = Math.abs(font.ascent * scale);
const descent = Math.abs(font.descent * scale);

return {
width: glyphRun.advanceWidth * scale,
ascent,
descent,
lineSpacing: (ascent + descent) * 1.2, // Scale the height by 1.2 to add more padding.
};
}

Aside: Faking SVG text autolayout with <textPath>

While SVG doesn't support autolayout, there is still one trick we can use: <textPath>, which lets us draw text along a path, and there's nothing that requires that path to be continuous. Here's a demo of <textPath>:

Live Editor
Result
Loading...

Box height calculation

The box calculation makes several assumptions:

  • There is only a single line for the title and subtitle.
  • The box has a fixed width and padding.

This is represented by the following diagram:

Header: 50px heightDescription (calculated)Footer: 15px height
  • Header: 50px
  • Description: Height calculated using the measureInterText function.
  • Footer: 15px

To calculate the height of the description, use a naive approach that:

  • Measures the length of each word in the string along with a space: "{word} ".
  • Adds words to a line until the maximum width is reached, and then start the next line.
  • Returns the total number of lines required.

TODO

  • Drawing the box
  • Drawing arrows