vercel / satori Goto Github PK
View Code? Open in Web Editor NEWEnlightened library to convert HTML and CSS to SVG
Home Page: https://og-playground.vercel.app
License: Mozilla Public License 2.0
Enlightened library to convert HTML and CSS to SVG
Home Page: https://og-playground.vercel.app
License: Mozilla Public License 2.0
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:
fillRule
. Need to find a way to convert it to fill-rule
.<text>
, we will have to either render them with Resvg (not ideal), or convert them into paths.It should not fail silently.
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)
Could not load a variable font
The font loads
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
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
border: '8px solid gold'
to border: '8px dashed gold'
and compare SVG output to HTML outputCurrently it only supports scale(2, 2)
.
When there is text , but no font specified, we need to throw a proper error message.embedFont
is enabled
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).
This is critical for properly handling glyph fallback: e.g. rendering the text “hello, 你好” with font-family: latin_font, cjk_font
.
The one/two/three-value flex
shorthand should be supported as we already have flexGrow
, flexShrink
and flexBasis
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
["--*","-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)","--*"]
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.
[
{
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',
},
]
{
'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',
}],
}
Let's improve our test suite by adding code coverage.
https://vitest.dev/guide/features.html#coverage
In the past, we've used codecov.io to track this over time.
Currently opentype.js doesn't support woff2, which is usually 30% the size of woff. Fontkit might be a good alternative for this (but the lib is larger).
rauchg.com
is not centered properly in the SVG render but it is in the HTML render (centered relative to the black square)transform
operations are applied from left to right (should be the opposite).There is no current plan for RTL languages, opening this issue to track.
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>
Perhaps we can use lerna or changesets
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.
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.
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
).Since we now added font fallback with #52, the next step is to support mixed ascender, descender, baseline, line height and offset values.
To implement this feature, it would require text-overflow
, white-space
, and potentially line-clamp
.
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.
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:
Also need to have an option to disable this behavior.
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
Intl.Segmenter
was introduced here #12 but it's not yet implemented in Firefox (compatibility table), causing an error for /test
:
For example #95 should be shown in the playground rather than a console log.
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.
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.
There are some image resources, that have wrong content-type
header returned which results in a failed rendering, we should never trust that.
Lines 56 to 81 in efefb78
Props like fill
and stroke
need to be passed to the <svg>
element too:
Line 309 in 1f6f1a8
The current implementation is using flex and making each word a node, which is OK and working well. But a better way is to use a typography algorithm and measure it dynamically like this: https://github.com/protectwise/troika/blob/1b52fc9a9a5a3ae03c27d0d12a0f62c3d73fe599/packages/troika-flex-layout/src/FlexLayoutProcessor.js#L217
?content=<base64_source>
for people to shareThis makes it easier for people to report and reproduce bugs.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.