made it work for node.js runtime
This commit is contained in:
parent
bb6a728a47
commit
7c3ef0ad7d
50
src/emoji.ts
Normal file
50
src/emoji.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js.
|
||||||
|
*/
|
||||||
|
/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */
|
||||||
|
const U200D = String.fromCharCode(8205); // zero-width joiner
|
||||||
|
const UFE0Fg = /\uFE0F/g; // variation selector regex
|
||||||
|
export function getIconCode(char: string) {
|
||||||
|
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char);
|
||||||
|
}
|
||||||
|
function toCodePoint(unicodeSurrogates: string) {
|
||||||
|
var r: string[] = [],
|
||||||
|
c = 0,
|
||||||
|
p = 0,
|
||||||
|
i = 0;
|
||||||
|
while (i < unicodeSurrogates.length) {
|
||||||
|
c = unicodeSurrogates.charCodeAt(i++);
|
||||||
|
if (p) {
|
||||||
|
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16));
|
||||||
|
p = 0;
|
||||||
|
} else if (55296 <= c && c <= 56319) {
|
||||||
|
p = c;
|
||||||
|
} else {
|
||||||
|
r.push(c.toString(16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.join("-");
|
||||||
|
}
|
||||||
|
const apis = {
|
||||||
|
twemoji: (code: string) =>
|
||||||
|
"https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/" + code.toLowerCase() + ".svg",
|
||||||
|
openmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/",
|
||||||
|
blobmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/",
|
||||||
|
noto: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/",
|
||||||
|
fluent: (code: string) =>
|
||||||
|
"https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" + code.toLowerCase() + "_color.svg",
|
||||||
|
fluentFlat: (code: string) =>
|
||||||
|
"https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" + code.toLowerCase() + "_flat.svg",
|
||||||
|
};
|
||||||
|
export type EmojiType = keyof typeof apis;
|
||||||
|
export function loadEmoji(code: string, type?: EmojiType) {
|
||||||
|
// https://github.com/svgmoji/svgmoji
|
||||||
|
if (!type || !apis[type]) {
|
||||||
|
type = "twemoji";
|
||||||
|
}
|
||||||
|
const api = apis[type];
|
||||||
|
if (typeof api === "function") {
|
||||||
|
return fetch(api(code));
|
||||||
|
}
|
||||||
|
return fetch(`${api}${code.toUpperCase()}.svg`);
|
||||||
|
}
|
177
src/index.ts
Normal file
177
src/index.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import type { SatoriOptions } from "satori";
|
||||||
|
import { renderAsync } from "@resvg/resvg-js";
|
||||||
|
|
||||||
|
import { type EmojiType, getIconCode, loadEmoji } from "./emoji";
|
||||||
|
|
||||||
|
const satoriImport = import("satori");
|
||||||
|
const fallbackFont = fs.readFile(path.resolve(__dirname, "../vendor/noto-sans-v27-latin-regular.ttf"));
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
const languageFontMap = {
|
||||||
|
"ja-JP": "Noto+Sans+JP",
|
||||||
|
"ko-KR": "Noto+Sans+KR",
|
||||||
|
"zh-CN": "Noto+Sans+SC",
|
||||||
|
"zh-TW": "Noto+Sans+TC",
|
||||||
|
"zh-HK": "Noto+Sans+HK",
|
||||||
|
"th-TH": "Noto+Sans+Thai",
|
||||||
|
"bn-IN": "Noto+Sans+Bengali",
|
||||||
|
"ar-AR": "Noto+Sans+Arabic",
|
||||||
|
"ta-IN": "Noto+Sans+Tamil",
|
||||||
|
"ml-IN": "Noto+Sans+Malayalam",
|
||||||
|
"he-IL": "Noto+Sans+Hebrew",
|
||||||
|
"te-IN": "Noto+Sans+Telugu",
|
||||||
|
devanagari: "Noto+Sans+Devanagari",
|
||||||
|
kannada: "Noto+Sans+Kannada",
|
||||||
|
symbol: ["Noto+Sans+Symbols", "Noto+Sans+Symbols+2"],
|
||||||
|
math: "Noto+Sans+Math",
|
||||||
|
unknown: "Noto+Sans",
|
||||||
|
};
|
||||||
|
async function loadGoogleFont(fontFamily: string | string[], segment: string) {
|
||||||
|
if (!fontFamily || !segment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let o = `https://fonts.googleapis.com/css2?family=${fontFamily}&text=${encodeURIComponent(segment)}`,
|
||||||
|
s = (
|
||||||
|
await (
|
||||||
|
await fetch(o, {
|
||||||
|
headers: {
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).text()
|
||||||
|
).match(/src: url\((.+)\) format\('(opentype|truetype)'\)/);
|
||||||
|
if (!s) throw new Error("Failed to load font");
|
||||||
|
return fetch(s[1]).then((a) => a.arrayBuffer());
|
||||||
|
}
|
||||||
|
const assetCache = new Map();
|
||||||
|
const loadDynamicAsset = (emojiType?: EmojiType) => {
|
||||||
|
const loadDynamicFont = async (languageCode: string, text: string) => {
|
||||||
|
if (languageCode === "emoji") {
|
||||||
|
// It's an emoji, load the image.
|
||||||
|
return "data:image/svg+xml;base64," + btoa(await (await loadEmoji(getIconCode(text), emojiType)).text());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.hasOwn(languageFontMap, languageCode)) {
|
||||||
|
languageCode = "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fontData = await loadGoogleFont(languageFontMap[languageCode as keyof typeof languageFontMap], text);
|
||||||
|
if (fontData) {
|
||||||
|
return {
|
||||||
|
name: `satori_${languageCode}_fallback_${text}`,
|
||||||
|
data: fontData,
|
||||||
|
weight: 400,
|
||||||
|
style: "normal",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load dynamic font for", text, ". Error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return async (...args: Parameters<typeof loadDynamicFont>) => {
|
||||||
|
const cacheKey = JSON.stringify(args);
|
||||||
|
const cachedFont = assetCache.get(cacheKey);
|
||||||
|
if (cachedFont) {
|
||||||
|
return cachedFont;
|
||||||
|
}
|
||||||
|
|
||||||
|
const font = await loadDynamicFont(...args);
|
||||||
|
assetCache.set(cacheKey, font);
|
||||||
|
return font;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export declare type ImageResponseOptions = ConstructorParameters<typeof Response>[1] & {
|
||||||
|
/**
|
||||||
|
* The width of the image.
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @default 1200
|
||||||
|
*/
|
||||||
|
width?: number;
|
||||||
|
/**
|
||||||
|
* The height of the image.
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @default 630
|
||||||
|
*/
|
||||||
|
height?: number;
|
||||||
|
/**
|
||||||
|
* Display debug information on the image.
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
debug?: boolean;
|
||||||
|
/**
|
||||||
|
* A list of fonts to use.
|
||||||
|
*
|
||||||
|
* @type {{ data: ArrayBuffer; name: string; weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; style?: 'normal' | 'italic' }[]}
|
||||||
|
* @default Noto Sans Latin Regular.
|
||||||
|
*/
|
||||||
|
fonts?: SatoriOptions["fonts"];
|
||||||
|
/**
|
||||||
|
* Using a specific Emoji style. Defaults to `twemoji`.
|
||||||
|
*
|
||||||
|
* @link https://github.com/vercel/og#emoji
|
||||||
|
* @type {EmojiType}
|
||||||
|
* @default 'twemoji'
|
||||||
|
*/
|
||||||
|
emoji?: EmojiType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ImageResponse {
|
||||||
|
constructor(element: ReactElement, options: ImageResponseOptions = {}) {
|
||||||
|
const extendedOptions = Object.assign(
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const fontData = await fallbackFont;
|
||||||
|
const { default: satori } = await satoriImport;
|
||||||
|
const svg = await satori(element, {
|
||||||
|
width: extendedOptions.width,
|
||||||
|
height: extendedOptions.height,
|
||||||
|
debug: extendedOptions.debug,
|
||||||
|
fonts: extendedOptions.fonts || [
|
||||||
|
{
|
||||||
|
name: "sans serif",
|
||||||
|
data: fontData,
|
||||||
|
weight: 700,
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
loadAdditionalAsset: loadDynamicAsset(extendedOptions.emoji),
|
||||||
|
});
|
||||||
|
const image = await renderAsync(svg, {
|
||||||
|
fitTo: {
|
||||||
|
mode: "width",
|
||||||
|
value: extendedOptions.width,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
controller.enqueue(image.asPng());
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "image/png",
|
||||||
|
"cache-control": isDev ? "no-cache, no-store" : "public, immutable, no-transform, max-age=31536000",
|
||||||
|
...extendedOptions.headers,
|
||||||
|
},
|
||||||
|
status: extendedOptions.status,
|
||||||
|
statusText: extendedOptions.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user