@vercel/og 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/emoji/index.js +49 -0
- package/dist/index.edge.js +33 -20587
- package/dist/index.node.js +71 -20612
- package/dist/index.node.js.map +1 -1
- package/dist/language/index.js +103 -0
- package/dist/og.js +91 -0
- package/dist/types.d.ts +0 -54
- package/dist/types.js +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export class FontDetector {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.rangesByLang = {};
|
|
4
|
+
}
|
|
5
|
+
async detect(text, fonts) {
|
|
6
|
+
await this.load(fonts);
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const segment of text) {
|
|
9
|
+
const lang = this.detectSegment(segment, fonts);
|
|
10
|
+
if (lang) {
|
|
11
|
+
result[lang] = result[lang] || '';
|
|
12
|
+
result[lang] += segment;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
detectSegment(segment, fonts) {
|
|
18
|
+
for (const font of fonts) {
|
|
19
|
+
const range = this.rangesByLang[font];
|
|
20
|
+
if (range && checkSegmentInRange(segment, range)) {
|
|
21
|
+
return font;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
async load(fonts) {
|
|
27
|
+
let params = '';
|
|
28
|
+
const existingLang = Object.keys(this.rangesByLang);
|
|
29
|
+
const langNeedsToLoad = fonts.filter((font) => !existingLang.includes(font));
|
|
30
|
+
if (langNeedsToLoad.length === 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const font of langNeedsToLoad) {
|
|
34
|
+
params += `family=${font}&`;
|
|
35
|
+
}
|
|
36
|
+
params += 'display=swap';
|
|
37
|
+
const API = `https://fonts.googleapis.com/css2?${params}`;
|
|
38
|
+
const fontFace = await (await fetch(API, {
|
|
39
|
+
headers: {
|
|
40
|
+
// Make sure it returns TTF.
|
|
41
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36',
|
|
42
|
+
},
|
|
43
|
+
})).text();
|
|
44
|
+
this.addDetectors(fontFace);
|
|
45
|
+
}
|
|
46
|
+
addDetectors(input) {
|
|
47
|
+
const regex = /font-family:\s*'(.+?)';.+?unicode-range:\s*(.+?);/gms;
|
|
48
|
+
const matches = input.matchAll(regex);
|
|
49
|
+
for (const [, _lang, range] of matches) {
|
|
50
|
+
const lang = _lang.replaceAll(' ', '+');
|
|
51
|
+
if (!this.rangesByLang[lang]) {
|
|
52
|
+
this.rangesByLang[lang] = [];
|
|
53
|
+
}
|
|
54
|
+
this.rangesByLang[lang].push(...convert(range));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function convert(input) {
|
|
59
|
+
return input.split(', ').map((range) => {
|
|
60
|
+
range = range.replaceAll('U+', '');
|
|
61
|
+
const [start, end] = range.split('-').map((hex) => parseInt(hex, 16));
|
|
62
|
+
if (isNaN(end)) {
|
|
63
|
+
return start;
|
|
64
|
+
}
|
|
65
|
+
return [start, end];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function checkSegmentInRange(segment, range) {
|
|
69
|
+
const codePoint = segment.codePointAt(0);
|
|
70
|
+
if (!codePoint)
|
|
71
|
+
return false;
|
|
72
|
+
return range.some((val) => {
|
|
73
|
+
if (typeof val === 'number') {
|
|
74
|
+
return codePoint === val;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const [start, end] = val;
|
|
78
|
+
return start <= codePoint && codePoint <= end;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// @TODO: Support font style and weights, and make this option extensible rather
|
|
83
|
+
// than built-in.
|
|
84
|
+
// @TODO: Cover most languages with Noto Sans.
|
|
85
|
+
export const languageFontMap = {
|
|
86
|
+
'ja-JP': 'Noto+Sans+JP',
|
|
87
|
+
'ko-KR': 'Noto+Sans+KR',
|
|
88
|
+
'zh-CN': 'Noto+Sans+SC',
|
|
89
|
+
'zh-TW': 'Noto+Sans+TC',
|
|
90
|
+
'zh-HK': 'Noto+Sans+HK',
|
|
91
|
+
'th-TH': 'Noto+Sans+Thai',
|
|
92
|
+
'bn-IN': 'Noto+Sans+Bengali',
|
|
93
|
+
'ar-AR': 'Noto+Sans+Arabic',
|
|
94
|
+
'ta-IN': 'Noto+Sans+Tamil',
|
|
95
|
+
'ml-IN': 'Noto+Sans+Malayalam',
|
|
96
|
+
'he-IL': 'Noto+Sans+Hebrew',
|
|
97
|
+
'te-IN': 'Noto+Sans+Telugu',
|
|
98
|
+
devanagari: 'Noto+Sans+Devanagari',
|
|
99
|
+
kannada: 'Noto+Sans+Kannada',
|
|
100
|
+
symbol: ['Noto+Sans+Symbols', 'Noto+Sans+Symbols+2'],
|
|
101
|
+
math: 'Noto+Sans+Math',
|
|
102
|
+
unknown: 'Noto+Sans',
|
|
103
|
+
};
|
package/dist/og.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { loadEmoji, getIconCode } from './emoji';
|
|
2
|
+
import { FontDetector, languageFontMap } from './language';
|
|
3
|
+
async function loadGoogleFont(font, text) {
|
|
4
|
+
if (!font || !text)
|
|
5
|
+
return;
|
|
6
|
+
const API = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`;
|
|
7
|
+
const css = await (await fetch(API, {
|
|
8
|
+
headers: {
|
|
9
|
+
// Make sure it returns TTF.
|
|
10
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1',
|
|
11
|
+
},
|
|
12
|
+
})).text();
|
|
13
|
+
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/);
|
|
14
|
+
if (!resource)
|
|
15
|
+
throw new Error('Failed to download dynamic font');
|
|
16
|
+
const res = await fetch(resource[1]);
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
throw new Error('Failed to download dynamic font. Status: ' + res.status);
|
|
19
|
+
}
|
|
20
|
+
return res.arrayBuffer();
|
|
21
|
+
}
|
|
22
|
+
const detector = new FontDetector();
|
|
23
|
+
const assetCache = new Map();
|
|
24
|
+
const loadDynamicAsset = ({ emoji }) => {
|
|
25
|
+
const fn = async (code, text) => {
|
|
26
|
+
if (code === 'emoji') {
|
|
27
|
+
// It's an emoji, load the image.
|
|
28
|
+
return (`data:image/svg+xml;base64,` +
|
|
29
|
+
btoa(await (await loadEmoji(getIconCode(text), emoji)).text()));
|
|
30
|
+
}
|
|
31
|
+
const codes = code.split('|');
|
|
32
|
+
// Try to load from Google Fonts.
|
|
33
|
+
const names = codes
|
|
34
|
+
.map((code) => languageFontMap[code])
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.flat();
|
|
37
|
+
if (names.length === 0)
|
|
38
|
+
return [];
|
|
39
|
+
try {
|
|
40
|
+
const textByFont = await detector.detect(text, names);
|
|
41
|
+
const fonts = Object.keys(textByFont);
|
|
42
|
+
const fontData = await Promise.all(fonts.map((font) => loadGoogleFont(font, textByFont[font])));
|
|
43
|
+
return fontData.map((data, index) => ({
|
|
44
|
+
name: `satori_${codes[index]}_fallback_${text}`,
|
|
45
|
+
data,
|
|
46
|
+
weight: 400,
|
|
47
|
+
style: 'normal',
|
|
48
|
+
lang: codes[index] === 'unknown' ? undefined : codes[index],
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
console.error('Failed to load dynamic font for', text, '. Error:', e);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return async (...args) => {
|
|
56
|
+
const key = JSON.stringify(args);
|
|
57
|
+
const cache = assetCache.get(key);
|
|
58
|
+
if (cache)
|
|
59
|
+
return cache;
|
|
60
|
+
const asset = await fn(...args);
|
|
61
|
+
assetCache.set(key, asset);
|
|
62
|
+
return asset;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
export default async function render(satori, resvg, opts, defaultFonts, element) {
|
|
66
|
+
const options = Object.assign({
|
|
67
|
+
width: 1200,
|
|
68
|
+
height: 630,
|
|
69
|
+
debug: false,
|
|
70
|
+
}, opts);
|
|
71
|
+
const svg = await satori(element, {
|
|
72
|
+
width: options.width,
|
|
73
|
+
height: options.height,
|
|
74
|
+
debug: options.debug,
|
|
75
|
+
fonts: options.fonts || defaultFonts,
|
|
76
|
+
loadAdditionalAsset: loadDynamicAsset({
|
|
77
|
+
emoji: options.emoji,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
const resvgJS = new resvg.Resvg(svg, {
|
|
81
|
+
fitTo: {
|
|
82
|
+
mode: 'width',
|
|
83
|
+
value: options.width,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const pngData = resvgJS.render();
|
|
87
|
+
const pngBuffer = pngData.asPng();
|
|
88
|
+
pngData.free();
|
|
89
|
+
resvgJS.free();
|
|
90
|
+
return pngBuffer;
|
|
91
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
|
-
/// <reference types="react" />
|
|
3
|
-
/// <reference types="node" />
|
|
4
|
-
/// <reference types="node" />
|
|
5
2
|
import type { SatoriOptions } from 'satori';
|
|
6
3
|
import type { EmojiType } from './emoji';
|
|
7
4
|
import type { OutgoingHttpHeader } from 'http';
|
|
@@ -48,57 +45,6 @@ export type ImageResponseNodeOptions = ImageOptions & {
|
|
|
48
45
|
headers?: OutgoingHttpHeader[];
|
|
49
46
|
};
|
|
50
47
|
export type ImageResponseOptions = ImageOptions & ConstructorParameters<typeof Response>[1];
|
|
51
|
-
export interface FigmaImageResponseProps {
|
|
52
|
-
/**
|
|
53
|
-
* Link to the Figma template frame.
|
|
54
|
-
*
|
|
55
|
-
* You can get the URL in Figma by right-clicking a frame and selecting "Copy link".
|
|
56
|
-
* @example https://www.figma.com/file/QjGNQixWnhu300e1Xzdl2y/OG-Images?type=design&node-id=11356-2443&mode=design&t=yLROd7ro8mP1PxMY-4
|
|
57
|
-
*/
|
|
58
|
-
url: string;
|
|
59
|
-
/**
|
|
60
|
-
* A mapping between Figma layer name and the value you want to replace it with.
|
|
61
|
-
*
|
|
62
|
-
* @example Sets Figma text layer named "Title" to "How to create OG Images"
|
|
63
|
-
* ```js
|
|
64
|
-
* { "Title": "How to create OG Images" }
|
|
65
|
-
* ```
|
|
66
|
-
*
|
|
67
|
-
* @example Sets multiple Figma text layers and provides custom styles
|
|
68
|
-
* ```js
|
|
69
|
-
* {
|
|
70
|
-
* "Title": { value: "How to create OG Images", props: { color: "red", centerHorizontally: true } },
|
|
71
|
-
* "Description": { value: "A short story", props: { centerHorizontally: true } },
|
|
72
|
-
* }
|
|
73
|
-
* ```
|
|
74
|
-
*
|
|
75
|
-
* `centerHorizontally` centers text layer horizontally.
|
|
76
|
-
*/
|
|
77
|
-
template: Record<string, FigmaComplexTemplate | string>;
|
|
78
|
-
/**
|
|
79
|
-
* The font names must match the font names in Figma.
|
|
80
|
-
*/
|
|
81
|
-
fonts?: FontOptions[];
|
|
82
|
-
/**
|
|
83
|
-
* The same as {@link ImageResponseOptions} except `width` and `height`. `width` and `height` are automatically set from the Figma frame's size.
|
|
84
|
-
*/
|
|
85
|
-
imageResponseOptions?: Omit<ImageResponseOptions, 'width' | 'height'>;
|
|
86
|
-
}
|
|
87
|
-
export interface FigmaComplexTemplate {
|
|
88
|
-
value: string;
|
|
89
|
-
props?: {
|
|
90
|
-
centerHorizontally?: boolean;
|
|
91
|
-
} & React.CSSProperties;
|
|
92
|
-
}
|
|
93
|
-
type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
|
|
94
|
-
type Style = 'normal' | 'italic';
|
|
95
|
-
interface FontOptions {
|
|
96
|
-
data: Buffer | ArrayBuffer;
|
|
97
|
-
name: string;
|
|
98
|
-
weight?: Weight;
|
|
99
|
-
style?: Style;
|
|
100
|
-
lang?: string;
|
|
101
|
-
}
|
|
102
48
|
declare module 'react' {
|
|
103
49
|
interface HTMLAttributes<T> {
|
|
104
50
|
/**
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/og",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Generate Open Graph Images dynamically from HTML/CSS without a browser",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.node.js",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"./package.json": "./package.json"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
|
-
"
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"build": "pnpm typecheck && tsup && pnpm types && pnpm copy",
|
|
47
48
|
"types": "tsc --project tsconfig.json",
|
|
48
49
|
"copy": "node scripts/copy-vendors.js"
|
|
49
50
|
},
|