Skip to content
vercelPublic

About

Enlightened library to convert HTML and CSS to SVG

Topics

Resources

Contributing

Security policy

Stars

14.0k stars

Watchers

78 watching

Forks

Latest commit

 

History

467 Commits

Folders and files

Repository files navigation

Satori

Satori: Enlightened library to convert HTML and CSS to SVG.

Note

To use Satori in your project to generate PNG images like Open Graph images and social cards, check out our announcement and Vercel’s Open Graph Image Generation docs →

To use it in Next.js, take a look at the Open Graph Image Generation examples →

Overview

Satori supports the JSX syntax, which makes it very straightforward to use. Here’s an overview of the basic usage:

// api.jsx
import satori from 'satori'

const svg = await satori(
  <div style={{ color: 'black' }}>hello, world</div>,
  {
    width: 600,
    height: 400,
    fonts: [
      {
        name: 'Roboto',
        // Use `fs` (Node.js only) or `fetch` to read the font as Buffer/ArrayBuffer and provide `data` here.
        data: robotoArrayBuffer,
        weight: 400,
        style: 'normal',
      },
    ],
  },
)

Satori will render the element into a 600×400 SVG, and return the SVG string:

'<svg ...><path d="..." fill="black"></path></svg>'

To render it to a PNG, use Sharp, which draws SVG with librsvg:

import sharp from 'sharp'

const png = await sharp(Buffer.from(svg)).png().toBuffer()

Under the hood, it handles layout calculation, font, typography and more, to generate a SVG that matches the exact same HTML and CSS in a browser.


Documentation

JSX

Satori only accepts JSX elements that are pure and stateless. You can use a subset of HTML elements (see section below), or custom React components, but React APIs such as useState, useEffect, dangerouslySetInnerHTML are not supported.

Experimental: builtin JSX support

Satori has an experimental JSX runtime that you can use without having to install React. You can enable it on a per-file basis with @jsxImportSource pragmas. In the future, it will autocomplete only the subset of HTML elements and CSS properties that Satori supports for better type-safety.

/** @jsxRuntime automatic */
/** @jsxImportSource satori/jsx */

import satori from 'satori';
import { FC, JSXNode } from 'satori/jsx';

const MyComponent: FC<{ children: JSXNode }> = ({ children }) => (
  <div style={{ color: 'black' }}>{children}</div>
)

const svg = await satori(
  <MyComponent>hello, world</MyComponent>,
  options,
)

Use without JSX

If you don't have JSX transpiler enabled, you can simply pass React-elements-like objects that have type, props.children and props.style (and other properties too) directly:

await satori(
  {
    type: 'div',
    props: {
      children: 'hello, world',
      style: { color: 'black' },
    },
  },
  options
)

HTML Elements

Satori supports a limited subset of HTML and CSS features, due to its special use cases. In general, only these static and visible elements and properties that are implemented.

For example, the <input> HTML element, the cursor CSS property are not in consideration. And you can't use <style> tags or external resources via <link> or <script>.

Also, Satori does not guarantee that the SVG will 100% match the browser-rendered HTML output since Satori implements its own layout engine based on the SVG 1.1 spec.

You can find the list of supported HTML elements and their preset styles here.

Images

You can use <img> to embed images. However, width, and height attributes are recommended to set:

await satori(
  <img src="https://picsum.photos/200/300" width={200} height={300} />,
  options
)

When using background-image, the image will be stretched to fit the element by default if you don't specify the size.

If you want to render the generated SVG to another image format such as PNG, it would be better to use base64 encoded image data (or buffer) directly as props.src so no extra I/O is needed in Satori:

await satori(
  <img src="data:image/png;base64,..." width={200} height={300} />,
  // Or src={arrayBuffer}, src={buffer}
  options
)

Canvas (WebGL, experimental)

Note: This is experimental and may change or be removed in any release. It's only available from satori/experimental, so it doesn't add to the size of the satori import.

With satori/experimental, you can draw the content of a <canvas> with WebGL2 via the webgl prop. Satori calls it with a WebGL2 context sized to the canvas, then embeds what you drew as an image:

import satori from 'satori/experimental'

await satori(
  <canvas
    width={1200}
    height={630}
    style={{ width: '100%', height: '100%' }}
    webgl={(gl, { width, height }) => {
      gl.clearColor(0, 0.5, 1, 1)
      gl.clear(gl.COLOR_BUFFER_BIT)
      // Compile shaders, draw, etc.
    }}
  />,
  options
)

Each canvas gets a new WebGL2 context, which is destroyed with everything drawn into it afterwards:

  • In browsers and Web Workers, Satori uses OffscreenCanvas.
  • In Node.js 20.16+, Satori loads the gl package from your project, which must be a WebGL2 build of headless-gl such as an ANGLE + SwiftShader one (e.g. github:encharm/headless-gl). It runs shaders on the CPU. If you bundle your server code, keep gl external and include its files in the deployment.

To provide contexts yourself, e.g. to reuse them across renders, pass a createWebGLContext(width, height) option that returns a WebGL2 context. Satori doesn't destroy the contexts it returns.

  • The width and height attributes set the drawing buffer size (300×150 by default), like in browsers. The canvas is displayed at its CSS size, keeping that aspect ratio unless both CSS dimensions are set.
  • Before calling webgl, Satori binds the default framebuffer, sets the viewport to the whole drawing buffer, and clears it to transparent, like a new canvas. Afterwards, it reads the default framebuffer. The webgl callback can be async.
  • The drawing buffer is treated as premultiplied alpha unless the context was created with premultipliedAlpha: false.
  • Children of <canvas> are fallback content and are not rendered.
  • The satori import ignores the webgl prop and draws <canvas> like any other element.

If you use React's JSX types, add the webgl prop to them:

import type { WebGLCanvasRenderer } from 'satori/experimental'

declare module 'react' {
  interface CanvasHTMLAttributes<T> {
    webgl?: WebGLCanvasRenderer
  }
}

CSS

Satori uses Taffy to lay out Flexbox, Grid and block layouts, and its own inline layout for text and inline elements. It’s not a complete CSS implementation, but it supports most common CSS features. Properties marked “Supported” behave as in browsers; otherwise, the differences are listed:

Property Property Expanded Support Example
CSS Variables Supported Example
display Supported, with these differences: table, ruby and their inner values, and two-value syntax like inline flow-root, aren't supported and throw. The top-level element is laid out as a block, like the root element of a page. Form controls like button are inline instead of inline-block, and have no default styles.
position Positioned inline elements (with display: inline) aren't the containing block of their absolutely positioned descendants, the nearest positioned block is. Sticky inline elements aren't moved.
float, clear A float in the middle of a paragraph is placed at the start of the paragraph, instead of on the line it's in. Each line is shortened by the floats beside its top, so a line that is taller than its text can overlap a float that starts lower. lineClamp, textOverflow: ellipsis and textWrap don't apply to text after a float.
zIndex Supported
visibility Supported
color Colors that SVG renderers may not support are converted to rgb() and rgba(), see Colors. Colors outside the sRGB gamut, like color(display-p3 1 0 0), are clipped to it. light-dark() always uses the light color, since color-scheme isn't supported. System colors like Canvas aren't supported.
margin
marginTopSupported
marginRightSupported
marginBottomSupported
marginLeftSupported
Position
topSupported
rightSupported
bottomSupported
leftSupported
insetSupported
Size
widthSupported. fit-content() is also supported, which Chrome doesn't support yet
heightSupported. fit-content() is also supported, which Chrome doesn't support yet
aspectRatioSupported
Min & max size
minWidthfit-content and stretch are ignored. fit-content() with a length is supported, which Chrome doesn't support yet
minHeightmin-content, max-content, fit-content and stretch are ignored
maxWidthfit-content and stretch are ignored. fit-content() with a length is supported, which Chrome doesn't support yet
maxHeightmin-content, max-content, fit-content and stretch are ignored
border
Width (borderWidth, borderTopWidth, ...)Supported
Style (borderStyle, borderTopStyle, ...)On rounded corners, dots are spaced evenly along each side, while Chrome draws square dots on the straight parts of thick dotted borders
Color (borderColor, borderTopColor, ...)Supported
Shorthand (border, borderTop, ...)Supported
outline, outlineWidth, outlineStyle, outlineColor, outlineOffset Outlines of inline elements aren't drawn, and auto is drawn like solid instead of as a focus ring. Dots and dashes are spaced like those of borders.
borderRadius
borderTopLeftRadiusSupported
borderTopRightRadiusSupported
borderBottomLeftRadiusSupported
borderBottomRightRadiusSupported
ShorthandSupported
cornerShape
ValuesSupported
Corner longhands (cornerTopLeftShape, cornerTopRightShape, ...)Supported
Side shorthands (cornerTopShape, cornerRightShape, ...)Supported
Flex
flexDirectionSupported
flexWrapSupported
flexGrowSupported
flexShrinkSupported
flexBasisSupported
alignItemsSupported
alignContentSupported
alignSelfSupported
justifyContentSupported
gap, rowGap, columnGapSupported
orderSupported
Grid
gridTemplateColumns, gridTemplateRowsSupported except for subgrid, which throws, and masonry
gridTemplateAreasSupported
gridAutoColumns, gridAutoRowsSupported
gridAutoFlowSupported
gridRow, gridColumn, gridArea and their longhandsSupported
justifyItems, justifySelfSupported
placeItems, placeContent, placeSelfSupported
Shorthands (grid, gridTemplate)Supported
List
listStyleTypeThe Chinese, Japanese, Korean and Ethiopic counter styles, symbols() and names of @counter-style rules are drawn like decimal. Markers can't be styled, since ::marker isn't supported
listStylePositionSupported
listStyleImageOnly url() is supported, not gradients
Shorthand (listStyle)Supported
counterReset, counterIncrement, counterSetCounters other than list-item aren't displayed, since content and counter() aren't supported. Where a marker is placed, and how many items a reversed() counter counts, are found from the style prop and the default styles of elements, not their tw classes
Font
fontFamilyOnly the fonts passed in the fonts option are used. After the listed families, the other loaded fonts are used as fallbacks in the order they were passed, so generic families like serif don't select a font
fontSizeSupported
fontWeightBold isn't synthesized when no bold font is loaded
fontStyleItalic isn't synthesized when the font has no italic
fontFeatureSettingsSupported
fontKerningSupported
fontVariant, fontVariantCaps, fontVariantNumeric, fontVariantLigatures, fontVariantPosition, fontVariantEastAsian, fontVariantAlternates, fontVariantEmojiApplied with the OpenType features of the font. Small caps aren't synthesized when the font has none, fontVariantAlternates only supports historical-forms, since @font-feature-values isn't supported, and fontVariantEmoji has no effect
Text
tabSizeSupported
textAlignstart and end are always left and right, since direction isn't supported. match-parent isn't supported
textIndentSupported except for the hanging and each-line keywords
verticalAlignmiddle, top, bottom, text-top and text-bottom only apply to images and other atomic inlines, not to inline elements
textTransformSupported. full-width and full-size-kana are also supported, which Chrome doesn't support yet
textOverflowellipsis only applies to text without inline elements in it
textDecorationDecorations are drawn per element: textDecoration: none on a descendant removes the parent's lines, and a descendant's own decoration replaces them instead of adding to them. textUnderlinePosition isn't supportedExample
textShadowSupported
lineHeightLine heights are rounded to whole pixels
letterSpacingIsn't added after the last character
wordSpacingSupported
whiteSpacebreak-spaces is laid out like pre-wrap
wordBreakauto-phrase is laid out like normal
overflowWrapSupported, also as wordWrap
textWrapbalance and pretty only apply to text without inline elements in it, and pretty is approximated
Background
backgroundSupported
backgroundColorSupported
backgroundImageSupported except for image-set() and cross-fade()
backgroundPositionSupported
backgroundSizeSupportedExample
backgroundClipSupported, also as WebkitBackgroundClip
backgroundRepeatSupported
backgroundOriginSupported
backgroundBlendModeSupported
transform
Translate (translate, translateX, translateY, translateZ, translate3d)Supported
Rotate (rotate, rotateX, rotateY, rotateZ, rotate3d)Supported
Scale (scale, scaleX, scaleY, scaleZ, scale3d)Supported
Skew (skew, skewX, skewY)Supported
Matrix (matrix, matrix3d)Supported
Perspective (perspective)Approximated, see the notes below
The transform attribute of inline <svg>Supported
Individual properties (translate, rotate, scale)Supported
transformOrigin Supported
transformStyle Supported, see the notes below
backfaceVisibility Supported
perspective Approximated, see the notes below
perspectiveOrigin Supported
objectFit Supported Example
objectPosition Supported Example
opacity Supported
mixBlendMode Supported
isolation Supported
boxSizing Supported
boxShadow Supported
overflow, overflowX, overflowY Scrollbars aren't drawn and take no space, like overlay scrollbars, so scroll and auto look like hidden.
overflowClipMargin Supported
filter Supported except for url()
backdropFilter Supported except for url()
clipPath Supported except for url() and geometry boxes like content-box Example
lineClamp Only applies to text without inline elements in it. This is also true for WebkitLineClamp, which applies with display: -webkit-box and WebkitBoxOrient: vertical Example
Mask
maskImageSupported, also as WebkitMaskImageExample
maskPositionSupportedExample
maskSizeSupportedExample
maskRepeatSupportedExample
maskOriginSupported. The mask shorthand, maskClip, maskMode and maskComposite aren't supported
WebkitTextStroke WebkitTextStrokeWidth Supported
WebkitTextStrokeColor Supported
WebkitTextFillColor Supported
paintOrder Supported

Note:

  1. In 3D transforms, elements in a preserve-3d context are drawn back to front by the depth of their centers, and elements that intersect aren't cut where they cross. Perspective is approximated: each element is drawn with the affine transform closest to it around its center, which is exact for elements facing the viewer, such as translateZ(). satori/experimental draws perspective exactly.
  2. Percentages in calc(), min(), max() and clamp() only work in sizes, margins, paddings, insets, gaps, flexBasis and translations. Elsewhere, values with them are ignored.
  3. Properties that aren't listed are ignored, for example columns, direction, writingMode and fontStretch.
  4. Invalid values throw an error, which fails the whole render, unless they're ignored with onStyleError.

Perspective (experimental)

Note: This is experimental and may change or be removed in any release. It's only available from satori/experimental, so it doesn't add to the size of the satori import.

With satori/experimental, perspective from the perspective property and the perspective() function is drawn exactly:

import satori from 'satori/experimental'

await satori(
  <div style={{ display: 'flex', perspective: 600 }}>
    <div style={{ display: 'flex', transform: 'rotateY(35deg)' }}>Hello</div>
  </div>,
  options
)

SVG has no perspective transforms, so each element with perspective is split into triangles that are each drawn with an affine transform, within 0.5px of the exact position. This makes the SVG larger and slower to render: depending on the angle, an element is drawn up to a few hundred times.

Language and Typography

OpenType Features: Satori supports advanced typography features via HarfBuzz text shaping. Use the font-feature-settings CSS property to enable OpenType features such as:

  • Ligatures (liga, dlig, hlig)
  • Small caps (smcp, c2sc)
  • Stylistic sets (ss01-ss20)
  • Contextual alternates (calt)
  • Swashes (swsh, cswh)
  • And many more OpenType features

Example:

<div style={{ fontFeatureSettings: '"smcp" 1, "liga" 0' }}>
  This Text Uses Small Caps
</div>

HarfBuzz also improves glyph shaping for complex scripts such as Arabic. Full Unicode bidirectional layout is not yet supported, so mixed LTR and RTL text may not follow browser ordering.

Fonts

Satori currently supports three font formats: TTF, OTF and WOFF. Note that WOFF2 is not supported at the moment. You must specify the font if any text is rendered with Satori, and pass the font data as ArrayBuffer (web) or Buffer (Node.js):

await satori(
  <div style={{ fontFamily: 'Inter' }}>Hello</div>,
  {
    width: 600,
    height: 400,
    fonts: [
      {
        name: 'Inter',
        data: inter,
        weight: 400,
        style: 'normal',
      },
      {
        name: 'Inter',
        data: interBold,
        weight: 700,
        style: 'normal',
      },
    ],
  }
)

Multiple fonts can be passed to Satori and used in fontFamily.

Tip

We recommend you define global fonts instead of creating a new object and pass it to satori for better performance, if your fonts do not change. Read it for more detail

Emojis

To render custom images for specific graphemes, you can use graphemeImages option to map the grapheme to an image source:

await satori(
  <div>Next.js is 🤯!</div>,
  {
    ...,
    graphemeImages: {
      '🤯': 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f92f.svg',
    },
  }
)

The image will be resized to the current font-size (both width and height) as a square.

Locales

Satori supports rendering text in different locales. You can specify the supported locales via the lang attribute:

await satori(
  <div lang="ja-JP">骨</div>
)

Same characters can be rendered differently in different locales, you can specify the locale when necessary to force it to render with a specific font and locale. Check out this example to learn more.

Supported locales are exported as the Locale enum type.

Dynamically Load Emojis and Fonts

Satori supports dynamically loading emoji images (grapheme pictures) and fonts. The loadAdditionalAsset function will be called when a text segment is rendered but missing the image or font:

await satori(
  <div>👋 你好</div>,
  {
    // `code` will be the detected language code, `emoji` if it's an Emoji, or `unknown` if not able to tell.
    // `segment` will be the content to render.
    loadAdditionalAsset: async (code: string, segment: string) => {
      if (code === 'emoji') {
        // if segment is an emoji
        return `data:image/svg+xml;base64,...`
      }

      // if segment is normal text
      return loadFontFromSystem(code)
    }
  }
)

Runtime Support

Satori can be directly used in browser, Node.js (>= 16), and Web Workers. It bundles its underlying WASM dependencies as base64-encoded strings and loads them at runtime.

If there is a limitation on dynamically loading WASM (e.g. Cloudflare Workers), you can use the Standalone Build which is mentioned below.

Standalone Build of Satori

Satori's standalone build doesn't include the WASM binary of its layout engine by default, and you need to load it manually before using Satori.

First, you need to download the layout.wasm binary from Satori build and provide it yourself. Let's use fetch to load it directly from the CDN as an example:

import satori, { init } from 'satori/standalone'

const res = await fetch('https://unpkg.com/satori/layout.wasm')
const layoutWasm = await res.arrayBuffer()

await init(layoutWasm)

// Now you can use satori as usual
const svg = await satori(...)

Of course, you can also load the layout.wasm file from your local disk via fs.readFile in Node.js or other methods.

Font Embedding

By default, Satori renders the text as <path> in SVG, instead of <text>. That means it embeds the font path data as inlined information, so succeeding processes (e.g. render the SVG on another platform) don’t need to deal with font files anymore.

You can turn off this behavior by setting embedFont to false, and Satori will use <text> instead:

const svg = await satori(
  <div style={{ color: 'black' }}>hello, world</div>,
  {
    ...,
    embedFont: false,
  },
)

Pixel Grid Rounding

Set pointScaleFactor to control how layout values are rounded to the pixel grid: they are rounded to multiples of 1 / pointScaleFactor px, or not rounded with 0. It defaults to 1, and higher values improve rendering precision on high-DPI displays.

const svg = await satori(
  <div style={{ color: 'black' }}>hello, world</div>,
  {
    ...,
    pointScaleFactor: 2,
  },
)

Invalid Styles

By default, an invalid or unsupported style declaration throws an error, which fails the whole render. With onStyleError, the declaration is ignored instead, and its error is passed to the callback:

const svg = await satori(
  <div style={{ display: 'table', color: 'black' }}>hello, world</div>,
  {
    ...,
    onStyleError: (error) => console.warn(error.message),
  },
)

Colors

Satori converts the colors that SVG renderers may not support to rgb() and rgba(). Sharp, for example, draws most of them black. They are:

  • lab(), lch(), oklab(), oklch() and color();
  • color-mix(), light-dark() and relative colors like rgb(from red r g b / 50%);
  • hwb(), rebeccapurple, and rgb() and hsl() without commas, like rgb(255 0 0 / 50%).

To keep them as they're written, for an SVG renderer that supports them, set convertColors to false:

const svg = await satori(
  <div style={{ color: 'oklch(0.7 0.2 30)' }}>hello, world</div>,
  {
    ...,
    convertColors: false,
  },
)

Colors in gradients are converted either way. SVG interpolates gradients in sRGB, so Satori adds stops to gradients that are interpolated in another color space: those with a color space like linear-gradient(in oklch, red, blue), and those in Oklab by default because they have colors from lab(), lch(), oklab(), oklch(), color(), color-mix() or relative colors.

Debug

To draw the bounding box for debugging, you can pass debug: true as an option:

const svg = await satori(
  <div style={{ color: 'black' }}>hello, world</div>,
  {
    ...,
    debug: true,
  },
)

Contribute

You can use the Vercel OG Image Playground to test and report bugs of Satori. Please follow our contribution guidelines before opening a Pull Request.


Author


About

Enlightened library to convert HTML and CSS to SVG

Topics

Resources

Contributing

Security policy

Stars

14.0k stars

Watchers

78 watching

Forks

Releases

Used by

Contributors

Languages