Skip to content

Commit

Permalink
refactor: load dynamic fonts (#442)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
LuciNyan and shuding committed Apr 16, 2023
1 parent 5998f5b commit 61dfc3b
Show file tree
Hide file tree
Showing 12 changed files with 367 additions and 123 deletions.
90 changes: 79 additions & 11 deletions playground/pages/api/font.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer | null> {
const API = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(
text
)}`
Expand All @@ -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()
}
103 changes: 56 additions & 47 deletions playground/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,14 @@ 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'

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 []

Expand Down Expand Up @@ -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)
Expand All @@ -117,40 +95,71 @@ 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,` +
btoa(await loadEmoji(emojiType, getIconCode(text)))
)
}

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)
Expand Down Expand Up @@ -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?.({
Expand Down
2 changes: 1 addition & 1 deletion playground/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2015",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
Expand Down
Loading

1 comment on commit 61dfc3b

@vercel
Copy link

@vercel vercel bot commented on 61dfc3b Apr 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.