|
| 1 | +import type { ReactElement } from "https://esm.sh/[email protected]"; |
| 2 | +import type { SatoriOptions } from "https://esm.sh/[email protected]"; |
| 3 | + |
| 4 | +import satori, { init as initSatori } from "https://esm.sh/[email protected]/wasm"; |
| 5 | +import { initStreaming } from "https://esm.sh/[email protected]"; |
| 6 | + |
| 7 | +import { |
| 8 | + initWasm, |
| 9 | + Resvg, |
| 10 | +} from "https://esm.sh/@resvg/[email protected]"; |
| 11 | +import { EmojiType, getIconCode, loadEmoji } from "./emoji.ts"; |
| 12 | + |
| 13 | +declare module "https://esm.sh/[email protected]" { |
| 14 | + interface HTMLAttributes<T> { |
| 15 | + /** |
| 16 | + * Specify styles using Tailwind CSS classes. This feature is currently experimental. |
| 17 | + * If `style` prop is also specified, styles generated with `tw` prop will be overridden. |
| 18 | + * |
| 19 | + * Example: |
| 20 | + * - `tw='w-full h-full bg-blue-200'` |
| 21 | + * - `tw='text-9xl'` |
| 22 | + * - `tw='text-[80px]'` |
| 23 | + * |
| 24 | + * @type {string} |
| 25 | + */ |
| 26 | + tw?: string; |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +const resvg_wasm = fetch( |
| 31 | + "https://cdn.jsdelivr.net/npm/@vercel/[email protected]/vendor/resvg.simd.wasm", |
| 32 | +).then((res) => res.arrayBuffer()); |
| 33 | + |
| 34 | +const yoga_wasm = fetch( |
| 35 | + "https://cdn.jsdelivr.net/npm/@vercel/[email protected]/vendor/yoga.wasm", |
| 36 | +); |
| 37 | + |
| 38 | +const fallbackFont = fetch( |
| 39 | + "https://cdn.jsdelivr.net/npm/@vercel/[email protected]/vendor/noto-sans-v27-latin-regular.ttf", |
| 40 | +).then((a) => a.arrayBuffer()); |
| 41 | + |
| 42 | +const initializedResvg = initWasm(resvg_wasm); |
| 43 | +const initializedYoga = initStreaming(yoga_wasm).then((yoga: unknown) => |
| 44 | + initSatori(yoga) |
| 45 | +); |
| 46 | + |
| 47 | +const isDev = Boolean(Deno.env.get("NETLIFY_LOCAL")); |
| 48 | + |
| 49 | +type ImageResponseOptions = ConstructorParameters<typeof Response>[1] & { |
| 50 | + /** |
| 51 | + * The width of the image. |
| 52 | + * |
| 53 | + * @type {number} |
| 54 | + * @default 1200 |
| 55 | + */ |
| 56 | + width?: number; |
| 57 | + /** |
| 58 | + * The height of the image. |
| 59 | + * |
| 60 | + * @type {number} |
| 61 | + * @default 630 |
| 62 | + */ |
| 63 | + height?: number; |
| 64 | + /** |
| 65 | + * Display debug information on the image. |
| 66 | + * |
| 67 | + * @type {boolean} |
| 68 | + * @default false |
| 69 | + */ |
| 70 | + debug?: boolean; |
| 71 | + /** |
| 72 | + * A list of fonts to use. |
| 73 | + * |
| 74 | + * @type {{ data: ArrayBuffer; name: string; weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; style?: 'normal' | 'italic' }[]} |
| 75 | + * @default Noto Sans Latin Regular. |
| 76 | + */ |
| 77 | + fonts?: SatoriOptions["fonts"]; |
| 78 | + /** |
| 79 | + * Using a specific Emoji style. Defaults to `twemoji`. |
| 80 | + * |
| 81 | + * @link https://github.com/vercel/og#emoji |
| 82 | + * @type {EmojiType} |
| 83 | + * @default 'twemoji' |
| 84 | + */ |
| 85 | + emoji?: EmojiType; |
| 86 | +}; |
| 87 | + |
| 88 | +// @TODO: Support font style and weights, and make this option extensible rather |
| 89 | +// than built-in. |
| 90 | +// @TODO: Cover most languages with Noto Sans. |
| 91 | +const languageFontMap = { |
| 92 | + "ja-JP": "Noto+Sans+JP", |
| 93 | + "ko-KR": "Noto+Sans+KR", |
| 94 | + "zh-CN": "Noto+Sans+SC", |
| 95 | + "zh-TW": "Noto+Sans+TC", |
| 96 | + "zh-HK": "Noto+Sans+HK", |
| 97 | + "th-TH": "Noto+Sans+Thai", |
| 98 | + "bn-IN": "Noto+Sans+Bengali", |
| 99 | + "ar-AR": "Noto+Sans+Arabic", |
| 100 | + "ta-IN": "Noto+Sans+Tamil", |
| 101 | + "ml-IN": "Noto+Sans+Malayalam", |
| 102 | + "he-IL": "Noto+Sans+Hebrew", |
| 103 | + "te-IN": "Noto+Sans+Telugu", |
| 104 | + devanagari: "Noto+Sans+Devanagari", |
| 105 | + kannada: "Noto+Sans+Kannada", |
| 106 | + symbol: ["Noto+Sans+Symbols", "Noto+Sans+Symbols+2"], |
| 107 | + math: "Noto+Sans+Math", |
| 108 | + unknown: "Noto+Sans", |
| 109 | +}; |
| 110 | + |
| 111 | +async function loadGoogleFont(fonts: string | string[], text: string) { |
| 112 | + // @TODO: Support multiple fonts. |
| 113 | + const font = Array.isArray(fonts) ? fonts.at(-1) : fonts; |
| 114 | + if (!font || !text) return; |
| 115 | + |
| 116 | + const API = `https://fonts.googleapis.com/css2?family=${font}&text=${ |
| 117 | + encodeURIComponent( |
| 118 | + text, |
| 119 | + ) |
| 120 | + }`; |
| 121 | + |
| 122 | + const css = await ( |
| 123 | + await fetch(API, { |
| 124 | + headers: { |
| 125 | + // Make sure it returns TTF. |
| 126 | + "User-Agent": |
| 127 | + "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", |
| 128 | + }, |
| 129 | + }) |
| 130 | + ).text(); |
| 131 | + |
| 132 | + const resource = css.match( |
| 133 | + /src: url\((.+)\) format\('(opentype|truetype)'\)/, |
| 134 | + ); |
| 135 | + if (!resource) throw new Error("Failed to load font"); |
| 136 | + |
| 137 | + return fetch(resource[1]).then((res) => res.arrayBuffer()); |
| 138 | +} |
| 139 | + |
| 140 | +type Asset = SatoriOptions["fonts"][0] | string; |
| 141 | + |
| 142 | +const assetCache = new Map<string, Asset | undefined>(); |
| 143 | +const loadDynamicAsset = ({ emoji }: { emoji?: EmojiType }) => { |
| 144 | + const fn = async ( |
| 145 | + code: keyof typeof languageFontMap | "emoji", |
| 146 | + text: string, |
| 147 | + ): Promise<Asset | undefined> => { |
| 148 | + if (code === "emoji") { |
| 149 | + // It's an emoji, load the image. |
| 150 | + return ( |
| 151 | + `data:image/svg+xml;base64,` + |
| 152 | + btoa(await (await loadEmoji(getIconCode(text), emoji)).text()) |
| 153 | + ); |
| 154 | + } |
| 155 | + |
| 156 | + // Try to load from Google Fonts. |
| 157 | + if (!languageFontMap[code]) code = "unknown"; |
| 158 | + |
| 159 | + try { |
| 160 | + const data = await loadGoogleFont(languageFontMap[code], text); |
| 161 | + |
| 162 | + if (data) { |
| 163 | + return { |
| 164 | + name: `satori_${code}_fallback_${text}`, |
| 165 | + data, |
| 166 | + weight: 400, |
| 167 | + style: "normal", |
| 168 | + }; |
| 169 | + } |
| 170 | + } catch (e) { |
| 171 | + console.error("Failed to load dynamic font for", text, ". Error:", e); |
| 172 | + } |
| 173 | + }; |
| 174 | + |
| 175 | + return async (...args: Parameters<typeof fn>) => { |
| 176 | + const key = JSON.stringify(args); |
| 177 | + const cache = assetCache.get(key); |
| 178 | + if (cache) return cache; |
| 179 | + |
| 180 | + const asset = await fn(...args); |
| 181 | + assetCache.set(key, asset); |
| 182 | + return asset; |
| 183 | + }; |
| 184 | +}; |
| 185 | + |
| 186 | +export class ImageResponse extends Response { |
| 187 | + constructor(element: ReactElement, options: ImageResponseOptions = {}) { |
| 188 | + const extendedOptions = Object.assign( |
| 189 | + { |
| 190 | + width: 1200, |
| 191 | + height: 630, |
| 192 | + debug: false, |
| 193 | + }, |
| 194 | + options, |
| 195 | + ); |
| 196 | + |
| 197 | + const result = new ReadableStream({ |
| 198 | + async start(controller) { |
| 199 | + try { |
| 200 | + console.log('init yoga wasm'); |
| 201 | + const fetchRes = await yoga_wasm; |
| 202 | + console.log(fetchRes); |
| 203 | + const res = await WebAssembly.instantiateStreaming(fetchRes); |
| 204 | + console.log(res); |
| 205 | + } catch (e) { |
| 206 | + console.log('error in init yoga'); |
| 207 | + console.log(e); |
| 208 | + } |
| 209 | + |
| 210 | + await initializedYoga; |
| 211 | + await initializedResvg; |
| 212 | + const fontData = await fallbackFont; |
| 213 | + |
| 214 | + const svg = await satori(element, { |
| 215 | + width: extendedOptions.width, |
| 216 | + height: extendedOptions.height, |
| 217 | + debug: extendedOptions.debug, |
| 218 | + fonts: extendedOptions.fonts || [ |
| 219 | + { |
| 220 | + name: "sans serif", |
| 221 | + data: fontData, |
| 222 | + weight: 700, |
| 223 | + style: "normal", |
| 224 | + }, |
| 225 | + ], |
| 226 | + loadAdditionalAsset: loadDynamicAsset({ |
| 227 | + emoji: extendedOptions.emoji, |
| 228 | + }), |
| 229 | + }); |
| 230 | + |
| 231 | + const resvgJS = new Resvg(svg, { |
| 232 | + fitTo: { |
| 233 | + mode: "width", |
| 234 | + value: extendedOptions.width, |
| 235 | + }, |
| 236 | + }); |
| 237 | + |
| 238 | + const res = resvgJS.render(); |
| 239 | + console.log(res); |
| 240 | + controller.enqueue(svg); |
| 241 | + controller.close(); |
| 242 | + }, |
| 243 | + }); |
| 244 | + console.log('result is ready') |
| 245 | + console.log(result); |
| 246 | + |
| 247 | + super(result, { |
| 248 | + headers: { |
| 249 | + "content-type": "image/png", |
| 250 | + "cache-control": isDev |
| 251 | + ? "no-cache, no-store" |
| 252 | + : "public, max-age=31536000, no-transform, immutable", |
| 253 | + ...extendedOptions.headers, |
| 254 | + }, |
| 255 | + status: extendedOptions.status, |
| 256 | + statusText: extendedOptions.statusText, |
| 257 | + }); |
| 258 | + } |
| 259 | +} |
0 commit comments