Code Monkey home page Code Monkey logo

satori's Issues

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.

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).

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>

Support `flex` shorthand

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

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)","--*"]

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',
    }],
}

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

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).

RTL languages

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

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>

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:

Support for ellipses

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

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.

TextNodes should be merged

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.

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.

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

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

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.

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.

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

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(

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.

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.