Skip to content

Commit 0b5e621

Browse files
authored
fix(takumi): restore non-latin glyph support regressed in v6.2.0 (#587)
1 parent 46b9f8b commit 0b5e621

11 files changed

Lines changed: 265 additions & 46 deletions

File tree

src/module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,7 +1329,7 @@ export const resolve = (import.meta.dev || import.meta.prerender) ? devResolve :
13291329
}
13301330
// Dev mode: convertWoff2ToTtf() may not have run via vite:compiled
13311331
// because OG components are lazily compiled. Run it now on first resolve.
1332-
if (!fontProcessingDone && convertedWoff2Files.size === 0 && hasSatoriRenderer() && hasNuxtFonts) {
1332+
if (!fontProcessingDone && convertedWoff2Files.size === 0 && (hasSatoriRenderer() || hasTakumiRenderer()) && hasNuxtFonts) {
13331333
if (pendingFontRequirements.length > 0)
13341334
await Promise.all(pendingFontRequirements)
13351335
await convertWoff2ToTtf({
@@ -1418,7 +1418,7 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
14181418
nuxt.hook('vite:compiled', async () => {
14191419
// Always persist font URL mapping (needed by all renderers for prerender/dev font resolution)
14201420
persistFontUrlMapping({ fontContext, buildDir: nuxt.options.buildDir, logger })
1421-
if (fontProcessingDone || !hasSatoriRenderer())
1421+
if (fontProcessingDone || (!hasSatoriRenderer() && !hasTakumiRenderer()))
14221422
return
14231423
// Skip until font requirements are populated (OG components are server-side,
14241424
// so onFontRequirements runs during the server Vite build, not the client build)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export type FontFormat = 'ttf' | 'otf' | 'woff' | 'woff2'
2+
3+
export function fontFormat(src: string): FontFormat {
4+
if (src.endsWith('.woff2'))
5+
return 'woff2'
6+
if (src.endsWith('.woff'))
7+
return 'woff'
8+
if (src.endsWith('.otf'))
9+
return 'otf'
10+
return 'ttf'
11+
}
12+
13+
/**
14+
* Pick the src to actually load for a parsed font entry.
15+
*
16+
* - When `preferStatic` is set (takumi), prefers the full static satoriSrc over a subset WOFF2
17+
* primary src. @nuxt/fonts CSS often ships only the latin subset for a family, so using the
18+
* subset would hide non-latin glyphs (devanagari, CJK) the static file covers.
19+
* - Otherwise, uses the primary src when the renderer can parse its format, falling back to
20+
* satoriSrc as a static alternative when the primary format is unsupported (satori + WOFF2).
21+
*
22+
* Returns null when no src on this entry can be parsed by the renderer.
23+
*/
24+
export function selectFontSource(
25+
f: { src: string, satoriSrc?: string },
26+
supportedFormats: Set<FontFormat>,
27+
preferStatic: boolean,
28+
): { src: string, isStaticFallback: boolean } | null {
29+
const primarySupported = supportedFormats.has(fontFormat(f.src))
30+
const satoriSupported = !!(f.satoriSrc && supportedFormats.has(fontFormat(f.satoriSrc)))
31+
if (preferStatic && satoriSupported && f.satoriSrc !== f.src)
32+
return { src: f.satoriSrc!, isStaticFallback: true }
33+
if (primarySupported)
34+
return { src: f.src, isStaticFallback: false }
35+
if (satoriSupported)
36+
return { src: f.satoriSrc!, isStaticFallback: true }
37+
return null
38+
}

src/runtime/server/og-image/fonts.ts

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,19 @@
11
import type { H3Event } from 'h3'
22
import type { FontConfig, OgImageRenderEventContext, RuntimeFontConfig } from '../../types'
3+
import type { FontFormat } from './font-source'
34
import { resolve } from '#og-image-virtual/public-assets.mjs'
45
import { fontRequirements, getComponentFontMap } from '#og-image/font-requirements'
56
import resolvedFonts from '#og-image/fonts'
67
import availableFonts from '#og-image/fonts-available'
78
import { logger } from '../../logger'
89
import { fontArrayCache, fontCache } from './cache/lru'
10+
import { fontFormat, selectFontSource } from './font-source'
911
import { renameSubsetFonts } from './font-subsets'
1012
import { codepointsIntersectRanges, parseUnicodeRange } from './unicode-range'
1113

1214
export { buildSubsetFamilyChain, renameSubsetFonts, resolveSubsetChain } from './font-subsets'
1315
export { codepointsIntersectRanges, extractCodepoints, parseUnicodeRange } from './unicode-range'
1416

15-
type FontFormat = 'ttf' | 'otf' | 'woff' | 'woff2'
16-
17-
function fontFormat(src: string): FontFormat {
18-
if (src.endsWith('.woff2'))
19-
return 'woff2'
20-
if (src.endsWith('.woff'))
21-
return 'woff'
22-
if (src.endsWith('.otf'))
23-
return 'otf'
24-
return 'ttf'
25-
}
26-
2717
export interface LoadFontsOptions {
2818
/**
2919
* Font formats the renderer can parse.
@@ -37,6 +27,13 @@ export interface LoadFontsOptions {
3727
fontFamilyOverride?: string
3828
/** Codepoints present in the template — fonts whose unicodeRange doesn't intersect are skipped */
3929
codepoints?: Set<number>
30+
/**
31+
* Prefer the static satoriSrc (full TTF/WOFF from fontless) over the primary src
32+
* (subset WOFF2 from @nuxt/fonts) when both are usable. Takumi uses this so non-Latin
33+
* glyphs (devanagari, CJK, etc.) render — @nuxt/fonts CSS often only ships latin
34+
* subsets for a family, but the fontless static file contains the full glyph set.
35+
*/
36+
preferStatic?: boolean
4037
}
4138

4239
async function loadFont(event: H3Event, font: FontConfig, src: string): Promise<BufferSource | null> {
@@ -151,47 +148,52 @@ export async function loadAllFonts(event: H3Event, options: LoadFontsOptions): P
151148
}
152149
}
153150

154-
// Filter out font subsets whose unicodeRange doesn't intersect the template's codepoints
155-
if (options.codepoints && options.codepoints.size > 0) {
156-
for (let i = fonts.length - 1; i >= 0; i--) {
157-
const f = fonts[i]!
158-
if (!f.unicodeRange)
159-
continue
160-
const ranges = parseUnicodeRange(f.unicodeRange)
161-
if (!ranges)
162-
continue
163-
if (!codepointsIntersectRanges(options.codepoints, ranges))
164-
fonts.splice(i, 1)
165-
}
151+
// Resolve the effective src per font up-front: primary src when supported, else satoriSrc.
152+
// Takumi opts into preferStatic so the full static TTF is used instead of the subset WOFF2 —
153+
// @nuxt/fonts CSS may only include latin subsets, and using the subset would hide non-latin
154+
// glyphs (devanagari, CJK) that the full static file covers.
155+
const resolved: Array<{ font: FontConfig, src: string, isStaticFallback: boolean }> = []
156+
for (const f of fonts) {
157+
const selection = selectFontSource(f, options.supportedFormats, options.preferStatic ?? false)
158+
if (selection)
159+
resolved.push({ font: f, ...selection })
166160
}
167161

168-
const results = await Promise.all(
169-
fonts.map(async (f) => {
170-
let src = f.src
171-
const srcFormat = fontFormat(f.src)
172-
173-
// If the primary src format isn't supported, try satoriSrc as alternative
174-
if (!options.supportedFormats.has(srcFormat)) {
175-
if (f.satoriSrc && options.supportedFormats.has(fontFormat(f.satoriSrc))) {
176-
src = f.satoriSrc
177-
}
178-
else {
179-
// No usable format available, skip this font
180-
return null
181-
}
182-
}
162+
// Filter out font subsets whose unicodeRange doesn't intersect the template's codepoints.
163+
// Skip entries using the full static fallback — its glyph coverage isn't described by the
164+
// parent @font-face unicodeRange (which only applied to the WOFF2 subset).
165+
const filtered = options.codepoints && options.codepoints.size > 0
166+
? resolved.filter(({ font: f, isStaticFallback }) => {
167+
if (isStaticFallback || !f.unicodeRange)
168+
return true
169+
const ranges = parseUnicodeRange(f.unicodeRange)
170+
if (!ranges)
171+
return true
172+
return codepointsIntersectRanges(options.codepoints!, ranges)
173+
})
174+
: resolved
183175

176+
const results = await Promise.all(
177+
filtered.map(async ({ font: f, src: initialSrc, isStaticFallback }) => {
178+
let src = initialSrc
184179
let data = await loadFont(event, f, src)
185-
// When satoriSrc fails to load (e.g. 404), try original src if its format is supported
186-
if (!data && src !== f.src && options.supportedFormats.has(srcFormat)) {
180+
// Fall back to primary src if the static alternative fails to load (e.g. 404)
181+
if (!data && src !== f.src && options.supportedFormats.has(fontFormat(f.src))) {
187182
data = await loadFont(event, f, f.src)
188-
if (data)
183+
if (data) {
189184
src = f.src
185+
isStaticFallback = false
186+
}
190187
}
191188
if (!data)
192189
return null
190+
// Reflect the src actually loaded so downstream dedupe (takumi) keys on the real binary.
191+
// Drop unicodeRange when using the full static fallback — the subset range doesn't apply
192+
// to the static TTF, and takumi's subset-chain logic would otherwise split the family.
193193
return {
194194
...f,
195+
src,
196+
...(isStaticFallback ? { unicodeRange: undefined } : {}),
195197
cacheKey: `${f.family}-${f.weight}-${f.style}-${src}`,
196198
data,
197199
} satisfies RuntimeFontConfig

src/runtime/server/og-image/takumi/renderer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp
172172
const { fontFamilyOverride, defaultFont } = getDefaultFontFamily(options)
173173
const nodes = await createTakumiNodes(event)
174174
const codepoints = extractCodepoints(nodes)
175-
const fonts = await timings.measure('font-load', () => loadFontsForRenderer(event, { supportedFormats: new Set(['ttf', 'woff2'] as const), component: options.component, fontFamilyOverride: fontFamilyOverride || defaultFont, codepoints }))
175+
const fonts = await timings.measure('font-load', () => loadFontsForRenderer(event, { supportedFormats: new Set(['ttf', 'woff2'] as const), preferStatic: true, component: options.component, fontFamilyOverride: fontFamilyOverride || defaultFont, codepoints }))
176176

177177
await event._nitro.hooks.callHook('nuxt-og-image:takumi:nodes' as any, nodes, event)
178178

@@ -272,7 +272,7 @@ const TakumiRenderer: Renderer = {
272272
async debug(e) {
273273
const [vnodes, fonts] = await Promise.all([
274274
createTakumiNodes(e),
275-
loadFontsForRenderer(e, { supportedFormats: new Set(['ttf', 'woff2'] as const), component: e.options.component }),
275+
loadFontsForRenderer(e, { supportedFormats: new Set(['ttf', 'woff2'] as const), preferStatic: true, component: e.options.component }),
276276
])
277277
return {
278278
vnodes,
10.8 KB
Loading

test/e2e/takumi-only-fonts.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createResolver } from '@nuxt/kit'
2+
import { $fetch, setup } from '@nuxt/test-utils/e2e'
3+
import { describe, expect, it } from 'vitest'
4+
import { fetchOgImage, setupImageSnapshots, SNAPSHOT_LOOSE } from '../utils'
5+
6+
const { resolve } = createResolver(import.meta.url)
7+
8+
// Regression: https://github.com/nuxt-modules/og-image/issues/586
9+
// Takumi-only setups (no satori component in the app) skipped fontless static-font
10+
// downloads after v6.2.0, leaving @nuxt/fonts' latin-subset WOFF2 as the only source
11+
// and causing non-latin glyphs (devanagari, CJK, etc.) to render as tofu.
12+
let hasTakumi = false
13+
try {
14+
await import('@takumi-rs/core')
15+
hasTakumi = true
16+
}
17+
catch {
18+
hasTakumi = false
19+
}
20+
21+
await setup({
22+
rootDir: resolve('../fixtures/takumi-only-fonts'),
23+
server: true,
24+
build: true,
25+
})
26+
27+
setupImageSnapshots(SNAPSHOT_LOOSE)
28+
29+
describe('takumi-only fonts', () => {
30+
it.runIf(hasTakumi)('downloads static fallback fonts for takumi-only apps', async () => {
31+
// Direct check on the build output: pre-v6.2.0 emitted these for takumi too; v6.2.0
32+
// narrowed the gate so a takumi-only app got no static TTFs at all and non-latin
33+
// scripts had nothing to fall back to.
34+
const [devanagari, poppins] = await Promise.all([
35+
$fetch('/_og-static-fonts/Noto_Sans_Devanagari-400-normal.ttf', { responseType: 'arrayBuffer' }) as Promise<ArrayBuffer>,
36+
$fetch('/_og-static-fonts/Poppins-400-normal.ttf', { responseType: 'arrayBuffer' }) as Promise<ArrayBuffer>,
37+
])
38+
expect(devanagari.byteLength).toBeGreaterThan(10_000)
39+
expect(poppins.byteLength).toBeGreaterThan(10_000)
40+
})
41+
42+
it.runIf(hasTakumi)('renders devanagari glyphs through takumi', async () => {
43+
const image = await fetchOgImage('/')
44+
expect(image).toMatchImageSnapshot({ customSnapshotIdentifier: 'takumi-only-devanagari' })
45+
}, 60000)
46+
47+
it.runIf(!hasTakumi)('skips when @takumi-rs/core not installed', () => {
48+
expect(true).toBe(true)
49+
})
50+
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<NuxtPage />
3+
</template>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script setup lang="ts">
2+
withDefaults(defineProps<{
3+
title?: string
4+
description?: string
5+
}>(), {
6+
title: 'चरन् वै मधु विन्दति',
7+
description: 'Takumi-only devanagari rendering',
8+
})
9+
</script>
10+
11+
<template>
12+
<div class="h-full w-full flex flex-col justify-center items-center bg-white">
13+
<h1 class="text-6xl text-rose-600 font-bold" style="font-family: 'Noto Sans Devanagari', sans-serif;">
14+
{{ title }}
15+
</h1>
16+
<p class="text-2xl text-gray-500 mt-4" style="font-family: 'Poppins', sans-serif;">
17+
{{ description }}
18+
</p>
19+
</div>
20+
</template>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { defineNuxtConfig } from 'nuxt/config'
2+
import NuxtOgImage from '../../../src/module'
3+
4+
// Regression: https://github.com/nuxt-modules/og-image/issues/586
5+
// Takumi-only fixture (no satori components) exercising a non-Latin family
6+
// (Noto Sans Devanagari) via @nuxt/fonts. Prior to the v6.2.0 regression the
7+
// build would download static TTFs for takumi too; v6.2.0 narrowed the gate to
8+
// satori only, leaving takumi with latin-subset WOFF2s that can't render devanagari.
9+
// Intentionally NOT extending ../.base — that fixture ships OgImageCommunity satori+takumi
10+
// templates which would leak satori into detectedRenderers and trigger the satori-gated
11+
// convertWoff2ToTtf path, hiding the regression we want to exercise here.
12+
export default defineNuxtConfig({
13+
modules: [
14+
'@nuxt/fonts',
15+
NuxtOgImage,
16+
],
17+
18+
fonts: {
19+
families: [
20+
{ name: 'Poppins', weights: [400, 700], global: true },
21+
{ name: 'Noto Sans Devanagari', weights: [400, 700], global: true },
22+
],
23+
},
24+
25+
ogImage: {
26+
debug: true,
27+
// Exclude the bundled OgImageCommunity templates so only takumi components are present —
28+
// otherwise community satori templates trick the module into thinking satori is in use.
29+
componentDirs: ['OgImage'],
30+
},
31+
32+
site: {
33+
url: 'https://example.com',
34+
},
35+
36+
compatibilityDate: '2025-01-13',
37+
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script setup lang="ts">
2+
import { defineOgImage } from '#imports'
3+
4+
defineOgImage('DevanagariCard.takumi', {
5+
title: 'चरन् वै मधु विन्दति',
6+
description: 'Takumi-only devanagari rendering',
7+
})
8+
</script>
9+
10+
<template>
11+
<div class="p-4">
12+
<h1>Takumi-only devanagari test</h1>
13+
</div>
14+
</template>

0 commit comments

Comments
 (0)