From 61dfc3b43903e8651e060c75b00255cfd454cbfe Mon Sep 17 00:00:00 2001 From: LuciNyan <22126563+LuciNyan@users.noreply.github.com> Date: Mon, 17 Apr 2023 04:09:27 +0800 Subject: [PATCH] refactor: load dynamic fonts (#442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hi! @shuding I have encountered a minor issue. After successfully retrieving the necessary font files on the backend server, I am simply uncertain about the optimal method for transmitting them back to the front end in a single response while also specifying the language for each file. Would you be able to provide me with some guidance on this matter? 🤔️ Closes: #367 --------- Co-authored-by: Shu Ding --- playground/pages/api/font.ts | 90 ++++++++++-- playground/pages/index.tsx | 103 +++++++------ playground/tsconfig.json | 2 +- playground/utils/font.ts | 136 ++++++++++++++++++ src/language.ts | 38 ++--- src/satori.ts | 27 ++-- ...ak-cjk-with-word-break-keep-all-1-snap.png | Bin 4925 -> 4928 bytes ...2\223\343\201\253\343\201\241\343\201\257" | Bin 0 -> 4388 bytes "test/assets/\344\275\240\345\245\275" | Bin 0 -> 2432 bytes ...2\223\343\201\253\343\201\241\343\201\257" | Bin 5096 -> 0 bytes test/language.test.tsx | 76 ++++++---- test/utils.tsx | 18 +-- 12 files changed, 367 insertions(+), 123 deletions(-) create mode 100644 playground/utils/font.ts create mode 100644 "test/assets/\343\201\223\343\202\223\343\201\253\343\201\241\343\201\257" create mode 100644 "test/assets/\344\275\240\345\245\275" delete mode 100644 "test/assets/\344\275\240\345\245\275\343\201\223\343\202\223\343\201\253\343\201\241\343\201\257" diff --git a/playground/pages/api/font.ts b/playground/pages/api/font.ts index 0f078a9e..4b793c02 100644 --- a/playground/pages/api/font.ts +++ b/playground/pages/api/font.ts @@ -1,18 +1,92 @@ import type { NextRequest } from 'next/server' +import { FontDetector, languageFontMap } from '../../utils/font' export const config = { runtime: 'experimental-edge', } +const detector = new FontDetector() + +// Our own encoding of multiple fonts and their code, so we can fetch them in one request. The structure is: +// [1 byte = X, length of language code][X bytes of language code string][4 bytes = Y, length of font][Y bytes of font data] +// Note that: +// - The language code can't be longer than 255 characters. +// - The language code can't contain non-ASCII characters. +// - The font data can't be longer than 4GB. +// When there are multiple fonts, they are concatenated together. +function encodeFontInfoAsArrayBuffer(code: string, fontData: ArrayBuffer) { + // 1 byte per char + const buffer = new ArrayBuffer(1 + code.length + 4 + fontData.byteLength) + const bufferView = new Uint8Array(buffer) + // 1 byte for the length of the language code + bufferView[0] = code.length + // X bytes for the language code + for (let i = 0; i < code.length; i++) { + bufferView[i + 1] = code.charCodeAt(i) + } + + // 4 bytes for the length of the font data + new DataView(buffer).setUint32(1 + code.length, fontData.byteLength, false) + + // Y bytes for the font data + bufferView.set(new Uint8Array(fontData), 1 + code.length + 4) + + return buffer +} + export default async function loadGoogleFont(req: NextRequest) { if (req.nextUrl.pathname !== '/api/font') return - const { searchParams, hostname } = new URL(req.url) - const font = searchParams.get('font') + const { searchParams } = new URL(req.url) + + const fonts = searchParams.getAll('fonts') const text = searchParams.get('text') - if (!font || !text) return + if (!fonts || fonts.length === 0 || !text) return + + const textByFont = await detector.detect(text, fonts) + const _fonts = Object.keys(textByFont) + + const encodedFontBuffers: ArrayBuffer[] = [] + let fontBufferByteLength = 0 + ;( + await Promise.all(_fonts.map((font) => fetchFont(textByFont[font], font))) + ).forEach((fontData, i) => { + if (fontData) { + // TODO: We should be able to directly get the language code here :) + const langCode = Object.entries(languageFontMap).find( + ([, v]) => v === _fonts[i] + )?.[0] + + if (langCode) { + const buffer = encodeFontInfoAsArrayBuffer(langCode, fontData) + encodedFontBuffers.push(buffer) + fontBufferByteLength += buffer.byteLength + } + } + }) + + const responseBuffer = new ArrayBuffer(fontBufferByteLength) + const responseBufferView = new Uint8Array(responseBuffer) + let offset = 0 + encodedFontBuffers.forEach((buffer) => { + responseBufferView.set(new Uint8Array(buffer), offset) + offset += buffer.byteLength + }) + + return new Response(responseBuffer, { + headers: { + 'Content-Type': 'font/woff', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }) +} + +async function fetchFont( + text: string, + font: string +): Promise { const API = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent( text )}` @@ -29,15 +103,9 @@ export default async function loadGoogleFont(req: NextRequest) { const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/) - if (!resource) return + if (!resource) return null const res = await fetch(resource[1]) - // Make sure not to mess it around with compression when developing it locally. - if (hostname === 'localhost') { - res.headers.delete('content-encoding') - res.headers.delete('content-length') - } - - return res + return res.arrayBuffer() } diff --git a/playground/pages/index.tsx b/playground/pages/index.tsx index dafa915a..8b229003 100644 --- a/playground/pages/index.tsx +++ b/playground/pages/index.tsx @@ -18,6 +18,7 @@ import { Panel, PanelGroup } from 'react-resizable-panels' import { loadEmoji, getIconCode, apis } from '../utils/twemoji' import Introduction from '../components/introduction' import PanelResizeHandle from '../components/panel-resize-handle' +import { languageFontMap } from '../utils/font' import playgroundTabs, { Tabs } from '../cards/playground-data' import previewTabs from '../cards/preview-tabs' @@ -25,29 +26,6 @@ import previewTabs from '../cards/preview-tabs' const cardNames = Object.keys(playgroundTabs) const editedCards: Tabs = { ...playgroundTabs } -// @TODO: Support font style and weights, and make this option extensible rather -// than built-in. -// @TODO: Cover most languages with Noto Sans. -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 init() { if (typeof window === 'undefined') return [] @@ -106,7 +84,7 @@ async function init() { function withCache(fn: Function) { const cache = new Map() return async (...args: string[]) => { - const key = args.join('|') + const key = args.join(':') if (cache.has(key)) return cache.get(key) const result = await fn(...args) cache.set(key, result) @@ -117,8 +95,8 @@ function withCache(fn: Function) { type LanguageCode = keyof typeof languageFontMap | 'emoji' const loadDynamicAsset = withCache( - async (emojiType: keyof typeof apis, code: LanguageCode, text: string) => { - if (code === 'emoji') { + async (emojiType: keyof typeof apis, _code: string, text: string) => { + if (_code === 'emoji') { // It's an emoji, load the image. return ( `data:image/svg+xml;base64,` + @@ -126,31 +104,62 @@ const loadDynamicAsset = withCache( ) } + const codes = _code.split('|') + // Try to load from Google Fonts. - let names = languageFontMap[code] - if (!names) code = 'unknown' + const names = codes + .map((code) => languageFontMap[code as keyof typeof languageFontMap]) + .filter(Boolean) + + if (names.length === 0) return [] + + const params = new URLSearchParams() + for (const name of names.flat()) { + params.append('fonts', name) + } + params.set('text', text) try { - if (typeof names === 'string') { - names = [names] - } + const response = await fetch(`/api/font?${params.toString()}`) - for (const name of names) { - const res = await fetch( - `/api/font?font=${encodeURIComponent(name)}&text=${encodeURIComponent( - text - )}` - ) - if (res.status === 200) { - const font = await res.arrayBuffer() - return { - name: `satori_${code}_fallback_${text}`, - data: font, - weight: 400, - style: 'normal', - lang: code === 'unknown' ? undefined : code, + if (response.status === 200) { + const data = await response.arrayBuffer() + const fonts: any[] = [] + + // Decode the encoded font format. + const decodeFontInfoFromArrayBuffer = (buffer: ArrayBuffer) => { + let offset = 0 + const bufferView = new Uint8Array(buffer) + + while (offset < bufferView.length) { + // 1 byte for font name length. + const languageCodeLength = bufferView[offset] + offset += 1 + let languageCode = '' + for (let i = 0; i < languageCodeLength; i++) { + languageCode += String.fromCharCode(bufferView[offset + i]) + } + offset += languageCodeLength + + // 4 bytes for font data length. + const fontDataLength = new DataView(buffer).getUint32(offset, false) + offset += 4 + const fontData = buffer.slice(offset, offset + fontDataLength) + offset += fontDataLength + + fonts.push({ + name: `satori_${languageCode}_fallback_${text}`, + data: fontData, + weight: 400, + style: 'normal', + lang: languageCode === 'unknown' ? undefined : languageCode, + }) } } + + decodeFontInfoFromArrayBuffer(data) + + return fonts } } catch (e) { console.error('Failed to load dynamic font for', text, '. Error:', e) @@ -549,8 +558,8 @@ const LiveSatori = withLive(function ({ width, height, debug, - loadAdditionalAsset: (...args: string[]) => - loadDynamicAsset(emojiType, ...args), + loadAdditionalAsset: (code: string, text: string) => + loadDynamicAsset(emojiType, code, text), }) if (renderType === 'png') { const url = (await renderPNG?.({ diff --git a/playground/tsconfig.json b/playground/tsconfig.json index 48062321..928ce22a 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2015", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/playground/utils/font.ts b/playground/utils/font.ts new file mode 100644 index 00000000..5430bc97 --- /dev/null +++ b/playground/utils/font.ts @@ -0,0 +1,136 @@ +type UnicodeRange = Array + +export class FontDetector { + private rangesByLang: { + [font: string]: UnicodeRange + } = {} + + public async detect( + text: string, + fonts: string[] + ): Promise<{ + [lang: string]: string + }> { + await this.load(fonts) + + const result: { + [lang: string]: string + } = {} + + for (const segment of text) { + const lang = this.detectSegment(segment, fonts) + if (lang) { + result[lang] = result[lang] || '' + result[lang] += segment + } + } + + return result + } + + private detectSegment(segment: string, fonts: string[]): string | null { + for (const font of fonts) { + const range = this.rangesByLang[font] + if (range && checkSegmentInRange(segment, range)) { + return font + } + } + + return null + } + + private async load(fonts: string[]): Promise { + let params = '' + + const existingLang = Object.keys(this.rangesByLang) + const langNeedsToLoad = fonts.filter((font) => !existingLang.includes(font)) + + if (langNeedsToLoad.length === 0) { + return + } + + for (const font of langNeedsToLoad) { + params += `family=${font}&` + } + params += 'display=swap' + + const API = `https://fonts.googleapis.com/css2?${params}` + + const fontFace = await ( + await fetch(API, { + headers: { + // Make sure it returns TTF. + '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', + }, + }) + ).text() + + this.addDetectors(fontFace) + } + + private addDetectors(input: string) { + const regex = /font-family:\s*'(.+?)';.+?unicode-range:\s*(.+?);/gms + const matches = input.matchAll(regex) + + for (const [, _lang, range] of matches) { + const lang = _lang.replaceAll(' ', '+') + if (!this.rangesByLang[lang]) { + this.rangesByLang[lang] = [] + } + + this.rangesByLang[lang].push(...convert(range)) + } + } +} + +function convert(input: string): UnicodeRange { + return input.split(', ').map((range) => { + range = range.replaceAll('U+', '') + const [start, end] = range.split('-').map((hex) => parseInt(hex, 16)) + + if (isNaN(end)) { + return start + } + + return [start, end] + }) +} + +function checkSegmentInRange(segment: string, range: UnicodeRange): boolean { + const codePoint = segment.codePointAt(0) + + if (!codePoint) return false + + return range.some((val) => { + if (typeof val === 'number') { + return codePoint === val + } else { + const [start, end] = val + return start <= codePoint && codePoint <= end + } + }) +} + +// @TODO: Support font style and weights, and make this option extensible rather +// than built-in. +// @TODO: Cover most languages with Noto Sans. +export 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', +} diff --git a/src/language.ts b/src/language.ts index a28e0e8d..421d8c52 100644 --- a/src/language.ts +++ b/src/language.ts @@ -45,6 +45,7 @@ const code = { kannada: /\p{scx=Kannada}/u, } as const +type SpecialCodeKey = keyof typeof specialCode type CodeKey = keyof typeof specialCode | keyof typeof code export type Locale = keyof typeof code export type LangCode = CodeKey | 'unknown' @@ -54,32 +55,33 @@ export function isValidLocale(x: any): x is Locale { return locales.includes(x) } -// Here we assume all characters from the passed in "segment" is in the same -// written language. So if the string contains at least one matched character, -// we determine it as the matched language. -// Since some characters may belong to multiple languages simultaneously, -// we adjust the order of the languages by locale. -export function detectLanguageCode(segment: string, locale?: Locale): LangCode { - // If `locale` matches, return it directly - if (locale && code[locale]) { - if (code[locale].test(segment)) { - return locale +export function detectLanguageCode( + segment: string, + locale?: Locale +): Array | ['unknown'] | [SpecialCodeKey] { + for (const c of Object.keys(specialCode) as SpecialCodeKey[]) { + if (specialCode[c].test(segment)) { + return [c] } } - for (const c of Object.keys(specialCode) as CodeKey[]) { - if (specialCode[c].test(segment)) { - return c - } + const languages = Object.keys(code).filter((lang) => + code[lang].test(segment) + ) as Locale[] + + if (languages.length === 0) { + return ['unknown'] } - for (const c of Object.keys(code) as CodeKey[]) { - if (code[c].test(segment)) { - return c + if (locale) { + const index = languages.findIndex((lang) => lang === locale) + if (index !== -1) { + languages.splice(index, 1) + languages.unshift(locale) } } - return 'unknown' + return languages } export function normalizeLocale(locale?: string): Locale | undefined { diff --git a/src/satori.ts b/src/satori.ts index 2561c3f1..172045b2 100644 --- a/src/satori.ts +++ b/src/satori.ts @@ -31,7 +31,7 @@ export type SatoriOptions = ( loadAdditionalAsset?: ( languageCode: string, segment: string - ) => Promise + ) => Promise> tailwindConfig?: TwConfig } @@ -147,13 +147,20 @@ export default async function satori( return null } processedWordsMissingFonts.add(key) - return options.loadAdditionalAsset(code, _segment).then((asset) => { - if (typeof asset === 'string') { - images[_segment] = asset - } else if (asset) { - fonts.push(asset) - } - }) + + return options + .loadAdditionalAsset(code, _segment) + .then((asset: any) => { + if (typeof asset === 'string') { + images[_segment] = asset + } else if (asset) { + if (Array.isArray(asset)) { + fonts.push(...asset) + } else { + fonts.push(asset) + } + } + }) }) ) ) @@ -183,8 +190,8 @@ function convertToLanguageCodes( const languageCodes = {} let wordsByCode = {} - for (let { word, locale } of segmentsMissingFont) { - const code = detectLanguageCode(word, locale) + for (const { word, locale } of segmentsMissingFont) { + const code = detectLanguageCode(word, locale).join('|') wordsByCode[code] = wordsByCode[code] || '' wordsByCode[code] += word } diff --git a/test/__image_snapshots__/word-break-test-tsx-test-word-break-test-tsx-word-break-break-word-should-not-break-cjk-with-word-break-keep-all-1-snap.png b/test/__image_snapshots__/word-break-test-tsx-test-word-break-test-tsx-word-break-break-word-should-not-break-cjk-with-word-break-keep-all-1-snap.png index 45d2c7328e7c42946138a3b5046395ed5973ca5b..06b0dcdbd88ea566f697e6b99f9f4f4685694a4b 100644 GIT binary patch literal 4928 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 F{tI&l5{Uo+ literal 4925 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 KyzIgBkNgLVpZ$RV literal 0 HcmV?d00001 diff --git "a/test/assets/\344\275\240\345\245\275" "b/test/assets/\344\275\240\345\245\275" new file mode 100644 index 0000000000000000000000000000000000000000..911584a5c9ecaa92a089c348ce06b6ba63743010 GIT binary patch literal 2432 zcmbtWdu&rx82`?_?R|7zA8Xf^J?|cEw~lqR-CB9Lu&r!^O}el#L

IV+Fdc*#-<0 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 (fonts = f)) describe('detectLanguageCode', () => { it('should detect emoji', async () => { - expect(detectLanguageCode('🔺')).toBe('emoji') - expect(detectLanguageCode('😀')).toBe('emoji') - expect(detectLanguageCode('㊗️')).toBe('emoji') - expect(detectLanguageCode('🧑🏻‍💻')).toBe('emoji') - expect(detectLanguageCode('hello 🌍')).toBe('emoji') - expect(detectLanguageCode('👋 vs 🌊')).toBe('emoji') + expect(detectLanguageCode('🔺')).toEqual(['emoji']) + expect(detectLanguageCode('😀')).toEqual(['emoji']) + expect(detectLanguageCode('㊗️')).toEqual(['emoji']) + expect(detectLanguageCode('🧑🏻‍💻')).toEqual(['emoji']) + expect(detectLanguageCode('hello 🌍')).toEqual(['emoji']) + expect(detectLanguageCode('👋 vs 🌊')).toEqual(['emoji']) }) it('should detect japanese(hiragana)', async () => { - expect(detectLanguageCode('こんにちは')).toBe('ja-JP') + expect(detectLanguageCode('こんにちは')).toEqual(['ja-JP']) }) it('should detect japanese(katakana)', async () => { - expect(detectLanguageCode('ハナミズキ')).toBe('ja-JP') + expect(detectLanguageCode('ハナミズキ')).toEqual(['ja-JP']) }) it('should detect japanese(kanji)', async () => { - expect(detectLanguageCode('桜')).toBe('ja-JP') + expect(detectLanguageCode('桜')).toEqual([ + 'ja-JP', + 'zh-CN', + 'zh-TW', + 'zh-HK', + ]) }) it('should detect japanese(hiragana) when locale is zh', async () => { - expect(detectLanguageCode('こんにちは')).toBe('ja-JP') + expect(detectLanguageCode('こんにちは')).toEqual(['ja-JP']) }) it('should detect japanese(katakana) when locale is zh', async () => { - expect(detectLanguageCode('ハナミズキ')).toBe('ja-JP') + expect(detectLanguageCode('ハナミズキ')).toEqual(['ja-JP']) }) it('should detect simplified chinese when locale is zh-cn', async () => { - expect(detectLanguageCode('我知道怎么说中文', 'zh-CN')).toBe('zh-CN') + expect(detectLanguageCode('我知道怎么说中文', 'zh-CN')).toEqual([ + 'zh-CN', + 'ja-JP', + 'zh-TW', + 'zh-HK', + ]) }) - it('should detect traditional chinese when locale is zh-cn', async () => { - expect(detectLanguageCode('我知道怎麼說中文', 'zh-CN')).toBe('zh-CN') + it('should detect traditional chinese(HK) when locale is zh-cn', async () => { + expect(detectLanguageCode('我知道怎麼說中文', 'zh-HK')).toEqual([ + 'zh-HK', + 'ja-JP', + 'zh-CN', + 'zh-TW', + ]) }) - it('should detect traditional chinese when locale is zh-tw', async () => { - expect(detectLanguageCode('我知道怎麼說中文', 'zh-TW')).toBe('zh-TW') + it('should detect traditional chinese(TW) when locale is zh-tw', async () => { + expect(detectLanguageCode('我知道怎麼說中文', 'zh-TW')).toEqual([ + 'zh-TW', + 'ja-JP', + 'zh-CN', + 'zh-HK', + ]) }) it('should detect korean', async () => { - expect(detectLanguageCode('안녕하세요')).toBe('ko-KR') + expect(detectLanguageCode('안녕하세요')).toEqual(['ko-KR']) }) it('should detect thai', async () => { - expect(detectLanguageCode('สวัสดี')).toBe('th-TH') + expect(detectLanguageCode('สวัสดี')).toEqual(['th-TH']) }) it('should detect arabic', async () => { - expect(detectLanguageCode('مرحبا')).toBe('ar-AR') + expect(detectLanguageCode('مرحبا')).toEqual(['ar-AR']) }) it('should detect tamil', async () => { - expect(detectLanguageCode('வணக்கம்')).toBe('ta-IN') + expect(detectLanguageCode('வணக்கம்')).toEqual(['ta-IN']) }) it('should detect bengali', async () => { - expect(detectLanguageCode('হ্যালো')).toBe('bn-IN') + expect(detectLanguageCode('হ্যালো')).toEqual(['bn-IN']) }) it('should detect malayalam', async () => { - expect(detectLanguageCode('ഹായ്')).toBe('ml-IN') + expect(detectLanguageCode('ഹായ്')).toEqual(['ml-IN']) }) it('should detect hebrew', async () => { - expect(detectLanguageCode('שלום')).toBe('he-IL') + expect(detectLanguageCode('שלום')).toEqual(['he-IL']) }) it('should detect telegu', async () => { - expect(detectLanguageCode('హలో')).toBe('te-IN') + expect(detectLanguageCode('హలో')).toEqual(['te-IN']) }) it('should detect devanagari', async () => { - expect(detectLanguageCode('नमस्ते')).toBe('devanagari') + expect(detectLanguageCode('नमस्ते')).toEqual(['devanagari']) }) it('should detect unknown', async () => { - expect(detectLanguageCode('wat')).toBe('unknown') + expect(detectLanguageCode('wat')).toEqual(['unknown']) }) it('should detect math', async () => { - expect(detectLanguageCode('ℵ')).toBe('math') + expect(detectLanguageCode('ℵ')).toEqual(['math']) }) it('should detect symbol', async () => { - expect(detectLanguageCode('☻')).toBe('symbol') + expect(detectLanguageCode('☻')).toEqual(['symbol']) }) it('should not crash when rendering Arabic letters', async () => { diff --git a/test/utils.tsx b/test/utils.tsx index 52a999fa..dad21929 100644 --- a/test/utils.tsx +++ b/test/utils.tsx @@ -18,14 +18,16 @@ export async function getDynamicAsset(text: string): Promise { return await readFile(fontPath) } -export async function loadDynamicAsset(code: unknown, text: string) { - return { - name: `satori_${code}_fallback_${text}`, - data: await getDynamicAsset(text), - weight: 400, - style: 'normal', - lang: code === 'unknown' ? undefined : code, - } +export async function loadDynamicAsset(code: string, text: string) { + return [ + { + name: `satori_${code}_fallback_${text}`, + data: await getDynamicAsset(text), + weight: 400, + style: 'normal', + lang: code === 'unknown' ? undefined : code.split('|')[0], + }, + ] } export function initFonts(callback: (fonts: SatoriOptions['fonts']) => void) {