From 7c3ef0ad7d6ccf8ccf59b2c39ea468d511804e87 Mon Sep 17 00:00:00 2001 From: m5r Date: Tue, 14 Feb 2023 20:27:14 +0100 Subject: [PATCH] made it work for node.js runtime --- src/emoji.ts | 50 +++++++++++++++ src/index.ts | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/emoji.ts create mode 100644 src/index.ts diff --git a/src/emoji.ts b/src/emoji.ts new file mode 100644 index 0000000..b4994e4 --- /dev/null +++ b/src/emoji.ts @@ -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`); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..bd18c60 --- /dev/null +++ b/src/index.ts @@ -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) => { + 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[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, + }); + } +}