Giter Club home page Giter Club logo

satori's Introduction

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 →

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

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

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.

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
)

CSS

Satori uses the same Flexbox layout engine as React Native, and it’s not a complete CSS implementation. However, it supports a subset of the spec that covers most common CSS features:

Property Property Expanded Supported Values Example
display none and flex, default to flex
position relative and absolute, default to relative
color Supported
margin
marginTopSupported
marginRightSupported
marginBottomSupported
marginLeftSupported
Position
topSupported
rightSupported
bottomSupported
leftSupported
Size
widthSupported
heightSupported
Min & max size
minWidthSupported except for min-content, max-content and fit-content
minHeightSupported except for min-content, max-content and fit-content
maxWidthSupported except for min-content, max-content and fit-content
maxHeightSupported except for min-content, max-content and fit-content
border
Width (borderWidth, borderTopWidth, ...)Supported
Style (borderStyle, borderTopStyle, ...)solid and dashed, default to solid
Color (borderColor, borderTopColor, ...)Supported
Shorthand (border, borderTop, ...)Supported, i.e. 1px solid gray
borderRadius
borderTopLeftRadiusSupported
borderTopRightRadiusSupported
borderBottomLeftRadiusSupported
borderBottomRightRadiusSupported
ShorthandSupported, i.e. 5px, 50% / 5px
Flex
flexDirectioncolumn, row, row-reverse, column-reverse, default to row
flexWrapwrap, nowrap, wrap-reverse, default to wrap
flexGrowSupported
flexShrinkSupported
flexBasisSupported except for auto
alignItemsstretch, center, flex-start, flex-end, baseline, normal, default to stretch
alignContentSupported
alignSelfSupported
justifyContentSupported
gapSupported
Font
fontFamilySupported
fontSizeSupported
fontWeightSupported
fontStyleSupported
Text
tabSizeSupported
textAlignstart, end, left, right, center, justify, default to start
textTransformnone, lowercase, uppercase, capitalize, defaults to none
textOverflowclip, ellipsis, defaults to clip
textDecorationSupport line types underline and line-through, and styles dotted, dashed, solidExample
textShadowSupported
lineHeightSupported
letterSpacingSupported
whiteSpacenormal, pre, pre-wrap, pre-line, nowrap, defaults to normal
wordBreaknormal, break-all, break-word, keep-all, defaults to normal
textWrapwrap, balance, defaults to wrap
Background
backgroundColorSupported, single value
backgroundImagelinear-gradient, radial-gradient, url, single value
backgroundPositionSupport single value
backgroundSizeSupport two-value size i.e. `10px 20%`
backgroundClipborder-box, text
backgroundRepeatrepeat, repeat-x, repeat-y, no-repeat, defaults to repeat
transform
Translate (translate, translateX, translateY)Supported
RotateSupported
Scale (scale, scaleX, scaleY)Supported
Skew (skew, skewX, skewY)Supported
transformOrigin Support one-value and two-value syntax (both relative and absolute values)
objectFit contain, cover, none, default to none
opacity Supported
boxShadow Supported
overflow visible and hidden, default to visible
filter Supported
clipPath Supported Example
lineClamp Supported Example
Mask
maskImagelinear-gradient(...), radial-gradient(...), url(...)Example
maskPositionSupportedExample
maskSizeSupport two-value size i.e. `10px 20%`Example
maskRepeatrepeat, repeat-x, repeat-y, no-repeat, defaults to repeatExample

Note:

  1. Three-dimensional transforms are not supported.
  2. There is no z-index support in SVG. Elements that come later in the document will be painted on top.
  3. box-sizing is set to border-box for all elements.
  4. calc isn't supported.
  5. overflow: hidden and transform can't be used together.
  6. currentcolor support is only available for the color property.

Language and Typography

Advanced typography features such as kerning, ligatures and other OpenType features are not currently supported.

RTL languages are not supported either.

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.

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 and WASM

Satori can be used in browser, Node.js (>= 16), and Web Workers.

By default, Satori depends on asm.js for the browser runtime, and native module in Node.js. However, you can optionally load WASM instead by importing satori/wasm and provide the initialized WASM module instance of Yoga to Satori:

import satori, { init } from 'satori/wasm'
import initYoga from 'yoga-wasm-web'

const yoga = initYoga(await fetch('/yoga.wasm').then(res => res.arrayBuffer()))
init(yoga)

await satori(...)

When running in the browser or in the Node.js environment, WASM files need to be hosted and fetched before initializing. asm.js can be bundled together with the lib. In this case WASM should be faster.

When running on the Node.js server, native modules should be faster. However there are Node.js environments where native modules are not supported (e.g. StackBlitz's WebContainers), or other JS runtimes that support WASM (e.g. Vercel's Edge Runtime, Cloudflare Workers, or Deno).

Additionally, there are other difference between asm.js, native and WASM, such as security and compatibility.

Overall there are many trade-offs between each choice, and it's better to pick the one that works the best for your use case.

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,
  },
)

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


satori's People

Contributors

akulsr0 avatar anaclumos avatar azaleta avatar btea avatar chibicode avatar dependabot[bot] avatar dimamachina avatar iteratetograceness avatar jackie1210 avatar jeetiss avatar koji avatar lucinyan avatar mottox2 avatar mrkldshv avatar multipletwigs avatar promer94 avatar sahithyandev avatar sam-apostel avatar searchableguy avatar shuding avatar simon-he95 avatar smaeda-ks avatar styfle avatar tehuel avatar wei avatar wong2 avatar wooorm avatar yana-gupta avatar yisibl avatar zignis avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

satori's Issues

Directional borders

This is currently tricky because of how borders work in SVG (stroke). Potentially we can have work arounds for pure <rect>s that don't have rounded corners.

If we can't make it happen, we should give better error message for it.

Optimizations

  • Merge paths. Currently each word (in the same text node) is generated as a path, due to how we handle typography and line breaking. But according to RazrFalcon/resvg#499, merging them together will produce better performance. Potentially solvable with #6, so each line will be a single path. Or we use a way to merge paths.
  • Lower precision (1). Another optimization is, since we’re giving the target image size to Satori, we can make the default precision for path coordinates be lower, such as rounding to integers. This will usually make image rendering faster.
  • Not sure if it’s approachable or if it adds more workload than it removes: we can try to compress the path data since most of the data is just path.

Support `flex` shorthand

The one/two/three-value flex shorthand should be supported as we already have flexGrow, flexShrink and flexBasis supported.

Change api for `fonts` from array to object

Depending on how many fonts and how many weights for each font, it might be better to use an object to avoid repeating the name of the font each time.

Before

[
    {
      name: 'Inter',
      data: fs.readFile(join(process.cwd(), 'assets', 'Roboto-Regular.ttf')),
      weight: 400,
      style: 'normal',
    },
    {
      name: 'Inter',
      data: fs.readFile(join(process.cwd(), 'assets', 'Roboto-Bold.ttf')),
      weight: 700,
      style: 'normal',
    },
    {
      name: 'Material Icons',
      data: fs.readFile(join(process.cwd(), 'assets', 'Material.ttf')),
      weight: 400,
      style: 'normal',
    },
  ]

After

{
    'Inter': [{
      data: fs.readFile(join(process.cwd(), 'assets', 'Roboto-Regular.ttf')),
      weight: 400,
      style: 'normal',
    },
    {
      data: fs.readFile(join(process.cwd(), 'assets', 'Roboto-Bold.ttf')),
      weight: 700,
      style: 'normal',
    }],
   'Material Icons': [{
      data: fs.readFile(join(process.cwd(), 'assets', 'Material.ttf')),
      weight: 400,
      style: 'normal',
    }],
}

Absolute positioning doesn't work well with `bottom`

Where top: 10 works but bottom: 0 doesn't:

<div
  style={{
    backgroundColor: 'white',
    height: '100%',
    width: '100%',
    position: 'relative',
  }}
>
  <div
    style={{
      position: 'absolute',
      // top: 10,
      // bottom: 0,
      fontFamily: 'Inter',
    }}
  >
    <b>Vercel Edge Network</b>
  </div>
</div>

Add pixel diff tests

Since we're adding PNG output in PR #39, we should also write tests to compare the image pixels to make sure we don't regress. This will allow us to make refactors to the SVG output and ensure the PNG remains the same, such as when we switched to <pattern>.

We can try out https://github.com/mapbox/pixelmatch

Switch to Harfbuzz

Satori currently relies on OpenType.js to parse font tables and generate the glyphs. I’m considering switching to Harfbuzz in the future because it supports a couple of more complex scripts and OpenType features. It can also improve the performance a bit as it’s a WASM port.

Pretty low priority at the moment as we need to improve the typography algorithm first.

RTL languages

There is no current plan for RTL languages, opening this issue to track.

Emoji support

Typefaces such as Emoji cannot be embedded as <path> in SVG, it would be great to have an option to still render it as <text>. Then the underlying library that supports font can still convert it to other image formats like PNG.

space is not preserved

Source

<div
  style={{
    display: 'flex',
    height: '100%',
    width: '100%',
    backgroundColor: 'white',
    fontSize: '2em'
  }}
>
  <div>
    Test <span style={{ color: 'blue' }}>satori</span> usage with space
  </div>
</div>

Result

image

Pass all props to `<svg>`

Props like fill and stroke need to be passed to the <svg> element too:

return `data:image/svg+xml;utf8,${`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${viewBox}">${translateSVGNodeToSVGString(

Support `<svg>` element

Satori should find a way to support inlined <svg> element:

await satori(
  <div style={{ color: 'black' }}>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
      <path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
    </svg>
    <span>hello, world</span>
  </div>
)

We got 2 choices,

A. When entering an svg element type, we can simply serialize the entire element to XML string, make it URL encoded, and put inside an img element as the source. We need to handle styles here too.
B. Just put that svg element inside the current svg tree. Doesn't sound like the standard way but it's easier, and things like currentColor should work in Resvg.

Challenges regarding this will be:

  1. We can only support the JSX representation of SVG here, where it has non-standard properties such as fillRule. Need to find a way to convert it to fill-rule.
  2. If that SVG contains <text>, we will have to either render them with Resvg (not ideal), or convert them into paths.

Support for ellipses

To implement this feature, it would require text-overflow, white-space, and potentially line-clamp.

Text align doesn't work

Bug:

CleanShot 2022-05-26 at 22 15 12@2x

<div
  style={{
    backgroundColor: 'white',
    height: '100%',
    width: '100%',
  }}
>
  <div
    style={{
      fontFamily: 'Inter',
      fontSize: 40,
      fontStyle: 'normal',
      color: 'black',
      whiteSpace: 'pre-wrap',
      background: 'red',
      width: '100%',
      textAlign: 'center'
    }}
  >
    Vercel Edge Network
  </div>
</div>

Certain emojis are parsed incorrectly

<div
  style={{
    display: 'flex',
    height: '100%',
    width: '100%',
    padding: '10px 20px',
    alignItems: 'center',
    alignContent: 'center',
    justifyContent: 'center',
    fontFamily: 'Inter, "Material Icons"',
    fontSize: 40,
    backgroundColor: 'white',
  }}
>
  ㊗️
</div>

image

Resolving image data by default

By default, Satori should fetch the resource and inline it as base64 for <img>. It works just like how we handle fonts (text → path) to inline all the possible dependencies.

Furthermore, it would be great if it can:

  • Resolve the image size
  • Cache the image data, which can be tricky because the image might be dynamic, or taking too much memory by the cache

Also need to have an option to disable this behavior.

Shareable snippet in playground

  • The initial tab should be a helloworld component
  • All tabs should memorize all edits in localStorage
  • The URL should have something like ?content=<base64_source> for people to share
  • There should be a "Share" button somewhere in the UI

This makes it easier for people to report and reproduce bugs.

Do not trust `content-type` of image response

There are some image resources, that have wrong content-type header returned which results in a failed rendering, we should never trust that.

imageType = (res.headers.get('content-type') || '').toLowerCase()
return res.arrayBuffer()
})
.then((data) => {
// `content-type` might be missing, we detect the type based on magic bytes.
if (!imageType) {
const magicBytes = new Uint8Array(data.slice(0, 4))
const magicString = [...magicBytes]
.map((byte) => byte.toString(16))
.join('')
switch (magicString) {
case '89504e47':
imageType = 'image/png'
break
case '47494638':
imageType = 'image/gif'
break
case 'ffd8ffe0':
case 'ffd8ffe1':
case 'ffd8ffe2':
case 'ffd8ffe3':
case 'ffd8ffe8':
imageType = 'image/jpeg'
break
}
}

Requiring a variable font causes a type error

Bug report

When satori's .addFonts method is called, it calls opentype which (in a Next.js app) throws the following error

error - TypeError: Cannot read properties of undefined (reading '259')
    at parseFvarAxis (webpack-internal:///(middleware)/./node_modules/@shuding/opentype.js/dist/opentype.module.js:10290:22)
    at Object.parseFvarTable [as parse] (webpack-internal:///(middleware)/./node_modules/@shuding/opentype.js/dist/opentype.module.js:10326:13)
    at Object.parseBuffer [as parse] (webpack-internal:///(middleware)/./node_modules/@shuding/opentype.js/dist/opentype.module.js:11618:33)
    at vt.addFonts (webpack-internal:///(middleware)/./node_modules/satori/dist/esm/index.wasm.js:18:20138)
    at new vt (webpack-internal:///(middleware)/./node_modules/satori/dist/esm/index.wasm.js:18:19783)
    at mu (webpack-internal:///(middleware)/./node_modules/satori/dist/esm/index.wasm.js:18:49554)
    at Object.start (webpack-internal:///(middleware)/./node_modules/@vercel/og/dist/index.js:10:2973)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)

Description / Observed Behavior

Could not load a variable font

Expected Behavior

The font loads

Reproduction

const font = fetch(
	new URL(
		'path/to/GeneralSans-Variable.ttf',
		import.meta.url,
	),
).then(async res => res.arrayBuffer());

// Then load with:
const options = {
	fonts: [
		{
			name: "General Sans",
			data: await font,
			style: "normal",
		},
	],
};

https://www.fontshare.com/fonts/general-sans

Additional Context

ali in ~/code/website on master λ yarn why satori
└─ @vercel/og@npm:0.0.15
   └─ satori@npm:0.0.38 (via npm:0.0.38)

Originally was going to report this issue in shuding/opentype.js, but the repo has issues disabled, so was not sure if it was more appropriate to go in https://github.com/opentypejs/opentype.js or here

Replace `css-line-break` with `Intl.Segmenter()`

css-line-break has bug with emojis (🏳️‍🌈 will be splitted into multiple characters), which requires us to include the https://github.com/orling/grapheme-splitter package. These two custom implementations add up ~180KB size to the bundle.

Instead we can use the native Intl.Segmenter API, which is landed in v8 and has a 85% global browser support. CloudFlare Worker should also support it as part of the Web standard.

We can make the locale configurable, but the en locale is already usable for most use cases (break by work or by grapheme).

Throw an error when CSS property or Tailwind API is not supported

We know which properties are supported (the list is in the readme) so we should throw an error when a CSS property isn't implemented so its clear to the user.

Here's the exhaustive list:

https://developer.mozilla.org/en-US/docs/Web/CSS/Reference

Details
["--*","-webkit-line-clamp","accent-color",":active","additive-symbols (@counter-style)","::after (:after)","align-content","align-items","align-self","align-tracks","all","","","","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timeline","animation-timing-function","@annotation","annotation()",":any-link","appearance","ascent-override (@font-face)","aspect-ratio","attr()","::backdrop","backdrop-filter","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-position-x","background-position-y","background-repeat","background-size","","::before (:before)",":blank","bleed (@page)","","block-overflow","block-size","blur()","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-end-end-radius","border-end-start-radius","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","@bottom-center","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","brightness()","calc()","caption-side","caret-color","@character-variant","character-variant()","@charset",":checked","circle()","clamp()","clear","clip","clip-path","","color","color-scheme","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","conic-gradient()","contain","content","content-visibility","contrast()","","counter-increment","counter-reset","counter-set","@counter-style","counters()","cross-fade()","cubic-bezier()","::cue","::cue-region",":current","cursor","","length#cap","length#ch","length#cm","angle#deg",":default",":defined","descent-override (@font-face)","",":dir","direction",":disabled","display","","","","","","","drop-shadow()","resolution#dpcm","resolution#dpi","resolution#dppx","element()","ellipse()",":empty","empty-cells",":enabled","env()","length#em","length#ex","fallback (@counter-style)","filter","",":first",":first-child","::first-letter (:first-letter)","::first-line (:first-line)",":first-of-type","fit-content()","","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","flex_value#fr","float",":focus",":focus-visible",":focus-within","font","font-display (@font-face)","@font-face","font-family","font-family (@font-face)","font-feature-settings","font-feature-settings (@font-face)","@font-feature-values","font-kerning","font-language-override","font-optical-sizing","font-size","font-size-adjust","font-stretch","font-stretch (@font-face)","font-style","font-style (@font-face)","font-synthesis","font-variant","font-variant (@font-face)","font-variant-alternates","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-variation-settings (@font-face)","font-weight","font-weight (@font-face)","forced-color-adjust","format()","","",":fullscreen",":future","angle#grad","gap","","::grammar-error","grayscale()","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","frequency#Hz","hanging-punctuation",":has","height","height (@viewport)","@historical-forms",":host()",":host-context()",":hover","hsl()","hsla()","hue-rotate()","hwb()","hyphenate-character","hyphens","","","image()","image-orientation","image-rendering","image-resolution","image-set()","@import",":in-range",":indeterminate","inherit","inherits (@property)","initial","initial-letter","initial-letter-align","initial-value (@property)","inline-size","input-security","inset","inset()","inset-block","inset-block-end","inset-block-start","inset-inline","inset-inline-end","inset-inline-start","",":invalid","invert()",":is","isolation","length#ic","length#in","justify-content","justify-items","justify-self","justify-tracks","frequency#kHz","@keyframes",":lang",":last-child",":last-of-type","@layer","layer()","layer() (@import)","leader()",":left","left","@left-bottom","","","letter-spacing","line-break","line-clamp","line-gap-override (@font-face)","line-height","line-height-step","linear-gradient()",":link","list-style","list-style-image","list-style-position","list-style-type","local()",":local-link","length#mm","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","margin-trim","::marker","marks (@page)","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","masonry-auto-flow","math-style","matrix()","matrix3d()","max()","max-block-size","max-height","max-height (@viewport)","max-inline-size","max-lines","max-width","max-width (@viewport)","max-zoom (@viewport)","@media","min()","min-block-size","min-height","min-height (@viewport)","min-inline-size","min-width","min-width (@viewport)","min-zoom (@viewport)","minmax()","mix-blend-mode","time#ms","@namespace","negative (@counter-style)",":not",":nth-child",":nth-col",":nth-last-child",":nth-last-col",":nth-last-of-type",":nth-of-type","","object-fit","object-position","offset","offset-anchor","offset-distance","offset-path","offset-position","offset-rotate",":only-child",":only-of-type","opacity","opacity()",":optional","order","orientation (@viewport)","@ornaments","ornaments()","orphans",":out-of-range","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-anchor","overflow-block","overflow-clip-margin","overflow-inline","overflow-wrap","overflow-x","overflow-y","overscroll-behavior","overscroll-behavior-block","overscroll-behavior-inline","overscroll-behavior-x","overscroll-behavior-y","Pseudo-classes","Pseudo-elements","length#pc","length#pt","length#px","pad (@counter-style)","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","@page","page-break-after","page-break-before","page-break-inside","paint()","paint-order","::part",":past","path()",":paused","","perspective","perspective()","perspective-origin",":picture-in-picture","place-content","place-items","place-self","::placeholder",":placeholder-shown",":playing","pointer-events","polygon()","","position","prefix (@counter-style)","print-color-adjust","@property","length#Q","quotes","angle#rad","length#rem","radial-gradient()","range (@counter-style)","",":read-only",":read-write","rect()","repeat()","repeating-linear-gradient()","repeating-radial-gradient()",":required","resize","","revert","rgb()","rgba()",":right","right","@right-bottom",":root","rotate","rotate()","rotate3d()","rotateX()","rotateY()","rotateZ()","row-gap","ruby-align","ruby-merge","ruby-position","saturate()","scale","scale()","scale3d()","scaleX()","scaleY()","scaleZ()",":scope","scroll-behavior","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","@scroll-timeline","scrollbar-color","scrollbar-gutter","scrollbar-width","::selection","selector()","sepia()","","shape-image-threshold","shape-margin","shape-outside","size (@page)","size-adjust (@font-face)","skew()","skewX()","skewY()","::slotted","speak-as (@counter-style)","::spelling-error","src (@font-face)","steps()","","@styleset","styleset()","@stylistic","stylistic()","suffix (@counter-style)","@supports","supports() (@import)","@swash","swash()","symbols (@counter-style)","symbols()","syntax (@property)","system (@counter-style)","time#s","angle#turn","tab-size","table-layout",":target","target-counter()","target-counters()","::target-text","target-text()",":target-within","text-align","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-skip","text-decoration-skip-ink","text-decoration-style","text-decoration-thickness","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-size-adjust","text-transform","text-underline-offset","text-underline-position","","","","top","@top-center","touch-action","transform","transform-box","","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","translate","translate()","translate3d()","translateX()","translateY()","translateZ()","type()","unicode-bidi","unicode-range (@font-face)","unset","","url()",":user-invalid","user-select",":user-valid","user-zoom (@viewport)","length#vh","length#vmax","length#vmin","length#vw",":valid","var()","vertical-align","@viewport","viewport-fit (@viewport)","visibility",":visited",":where","white-space","widows","width","width (@viewport)","will-change","word-break","word-spacing","word-wrap","writing-mode","resolution#x","z-index","zoom (@viewport)","--*"]

Bugs noticed in the playground

  • The rauchg.com is not centered properly in the SVG render but it is in the HTML render (centered relative to the black square)
  • CSS transform operations are applied from left to right (should be the opposite).

TextNodes should be merged

Dynamically load fonts for out-of-range characters from Google Fonts

Goal

When rendering dynamic content, especially user input, it's possible that it contains some characters which are not included in the loaded fonts.

We want to solve this problem in both Node and Edge runtimes.

Proposal

To solve this, we can make use of Google Fonts' ?text API:

https://fonts.googleapis.com/css2?family=Noto+Sans+JP&text=あ

Make sure to request this API with curl so it won't return modern formats such as woff2. This gives us a small font file that we can use as the fallback to render the dynamic content.

As for font choice, Google's Noto font family can be a good built-in fallback, which supports almost all languages in the world:

The Noto fonts are perfect for harmonious, aesthetic, and typographically correct global communication, in more than 1,000 languages and over 150 writing systems.

Implementation

  • Support for font-family fallback needs to be implemented first (#4).
  • Make satori an async API (await satori(...)) so it is able to load dynamic fonts (+Emojis). We can remove the graphemeImages option with a more user-friendly API (twemoji: true).
  • Satori should cache the font with a maximum fallback number of fonts (works like a LRU).
  • Satori should do a pre-pass to collect all the text nodes in the element. And then filter out characters that can't be rendered with specified fonts.
  • Satori then needs to detect which Google Font to use for a specific character. The Noto family is splitted into typefaces by language (because a opentype font can only have up to 65,535 glyphs), so some meta information (a mapping of "character → langauge & font name") of Note is needed. I can think of two ways for now:

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.