fix(canvas): render exact font faces

This commit is contained in:
Danila Poyarkov 2026-05-17 13:40:56 +03:00
parent f9034d4492
commit 3534a4a919
6 changed files with 219 additions and 31 deletions

View file

@ -14,7 +14,7 @@ import {
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Vector } from '#core/types'
import type { LabelCache } from './cache'
import type { CachedComponent, CachedSection, LabelCache } from './cache'
function measureGlyphWidth(font: Font, text: string): number {
const glyphIds = font.getGlyphIDs(text)
@ -78,6 +78,22 @@ function walkLabelTree(
return result
}
interface LabelHitContext {
canvasX: number
canvasY: number
zoom: number
font: Font
}
function labelHitContext(
canvasX: number,
canvasY: number,
zoom: number,
font: Font
): LabelHitContext {
return { canvasX, canvasY, zoom, font }
}
function hitCachedLabel<T extends { nodeId: string; absX: number; absY: number }>(
graph: SceneGraph,
items: readonly T[],
@ -93,6 +109,15 @@ function hitCachedLabel<T extends { nodeId: string; absX: number; absY: number }
return null
}
function hitCachedLabelWithContext<T extends { nodeId: string; absX: number; absY: number }>(
graph: SceneGraph,
items: readonly T[],
context: LabelHitContext,
hit: (node: SceneNode, item: T, context: LabelHitContext) => SceneNode | null
): SceneNode | null {
return hitCachedLabel(graph, items, (node, item) => hit(node, item, context))
}
function hitSectionTitle(
child: SceneNode,
ax: number,
@ -113,12 +138,40 @@ function hitSectionTitle(
return hitInRect(hit.x, hit.y, 0, pillY, pillW, pillH) ? child : null
}
export function hitTestSectionTitle(graph: SceneGraph, canvasX: number, canvasY: number, zoom: number, pageId: string, font: Font | null, labelCache?: LabelCache): SceneNode | null {
function hitCachedSectionTitle(
child: SceneNode,
section: CachedSection,
context: LabelHitContext
): SceneNode | null {
return hitSectionTitle(
child,
section.absX,
section.absY,
section.nested,
context.canvasX,
context.canvasY,
context.zoom,
context.font
)
}
export function hitTestSectionTitle(
graph: SceneGraph,
canvasX: number,
canvasY: number,
zoom: number,
pageId: string,
font: Font | null,
labelCache?: LabelCache
): SceneNode | null {
if (!font) return null
if (labelCache) {
return hitCachedLabel(graph, labelCache.getAllSections(), (child, section) =>
hitSectionTitle(child, section.absX, section.absY, section.nested, canvasX, canvasY, zoom, font)
return hitCachedLabelWithContext(
graph,
labelCache.getAllSections(),
labelHitContext(canvasX, canvasY, zoom, font),
hitCachedSectionTitle
)
}
@ -146,14 +199,35 @@ function hitComponentLabel(
return hitInRect(canvasX, canvasY, ax, labelY, labelW, labelH) ? child : null
}
export function hitTestComponentLabel(graph: SceneGraph, canvasX: number, canvasY: number, zoom: number, pageId: string, font: Font | null, labelCache?: LabelCache): SceneNode | null {
function hitCachedComponentLabel(
child: SceneNode,
component: CachedComponent,
context: LabelHitContext
): SceneNode | null {
const { canvasX, canvasY, zoom, font } = context
return hitComponentLabel(child, component.absX, component.absY, canvasX, canvasY, zoom, font)
}
export function hitTestComponentLabel(
graph: SceneGraph,
canvasX: number,
canvasY: number,
zoom: number,
pageId: string,
font: Font | null,
labelCache?: LabelCache
): SceneNode | null {
if (!font) return null
if (labelCache) {
return hitCachedLabel(graph, labelCache.getAllComponents(), (child, component) =>
hitComponentLabel(child, component.absX, component.absY, canvasX, canvasY, zoom, font)
const cachedHit = labelCache
? hitCachedLabelWithContext(
graph,
labelCache.getAllComponents(),
labelHitContext(canvasX, canvasY, zoom, font),
hitCachedComponentLabel
)
}
: null
if (cachedHit) return cachedHit
const LABEL_TYPES = new Set(['COMPONENT', 'COMPONENT_SET'])

View file

@ -118,14 +118,16 @@ function buildTruncateOpts(
function resolveParagraphFontFamilies(
primary: string,
style: string,
arabicFallbacks: readonly string[],
cjkFallbacks: readonly string[]
): string[] {
const key = `${primary}\0${arabicFallbacks.join('\0')}\0${cjkFallbacks.join('\0')}`
const renderPrimary = fontManager.renderFamily(primary, style)
const key = `${renderPrimary}\0${arabicFallbacks.join('\0')}\0${cjkFallbacks.join('\0')}`
const cached = fontFamilyCache.get(key)
if (cached) return cached
const families = [primary]
const families = [renderPrimary]
if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY)
families.push(...arabicFallbacks, ...cjkFallbacks)
@ -172,7 +174,7 @@ function addStyledRuns(
node: SceneNode,
baseColor: Float32Array,
baseFontSize: number,
fontFamilies: (primary: string) => string[],
fontFamilies: (primary: string, weight: number, italic?: boolean) => string[],
halfLeading: boolean
): void {
const ck = r.ck
@ -199,11 +201,15 @@ function addStyledRuns(
builder.pushStyle(
new ck.TextStyle({
color: runColor,
fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)),
fontFamilies: fontFamilies(
s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY),
s.fontWeight ?? node.fontWeight,
s.italic ?? node.italic
),
fontSize: runFontSize,
fontStyle: {
weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight,
slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright
weight: { value: 400 } as FontWeight,
slant: ck.FontSlant.Upright
},
letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0),
decoration: textDecorationValue(ck, s.textDecoration ?? node.textDecoration),
@ -236,8 +242,13 @@ export function buildParagraph(
const truncateOpts = buildTruncateOpts(node, baseFontSize)
const fontFamilies = (primary: string) =>
resolveParagraphFontFamilies(primary, arabicFallbacks, cjkFallbacks)
const fontFamilies = (primary: string, weight: number, italic = false) =>
resolveParagraphFontFamilies(
primary,
weightToStyle(weight, italic),
arabicFallbacks,
cjkFallbacks
)
const paraStyle = new ck.ParagraphStyle({
textAlign: getParagraphTextAlign(ck, node),
@ -245,11 +256,15 @@ export function buildParagraph(
...truncateOpts,
textStyle: {
color: baseColor,
fontFamilies: fontFamilies(node.fontFamily || DEFAULT_FONT_FAMILY),
fontFamilies: fontFamilies(
node.fontFamily || DEFAULT_FONT_FAMILY,
node.fontWeight,
node.italic
),
fontSize: baseFontSize,
fontStyle: {
weight: { value: node.fontWeight || 400 } as FontWeight,
slant: node.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright
weight: { value: 400 } as FontWeight,
slant: ck.FontSlant.Upright
},
letterSpacing: node.letterSpacing || 0,
decoration: textDecorationValue(ck, node.textDecoration),

View file

@ -176,6 +176,14 @@ export {
type FontInfo,
type LocalFontAccessState
} from './text/fonts'
export {
fontFaceFromFigmaFontName,
fontFaceRenderFamily,
normalizeFontStyleName,
parseFontStyle,
type FontFaceRef,
type ParsedFontStyle
} from './text/font-face'
export {
ARABIC_LOCAL_FALLBACK_FAMILIES,
ARABIC_REMOTE_FALLBACK_FAMILIES,

View file

@ -0,0 +1,61 @@
export interface ParsedFontStyle {
weight: number
italic: boolean
}
export interface FontFaceRef extends ParsedFontStyle {
family: string
style: string
postscriptName?: string
}
const FONT_STYLE_WEIGHTS: Array<[RegExp, number]> = [
[/(?:extra|ultra)?(?:thin|hairline)/u, 100],
[/(?:extra|ultra)light/u, 200],
[/light/u, 300],
[/(?:regular|normal|book|roman|plain)/u, 400],
[/medium/u, 500],
[/(?:semi|demi)bold/u, 600],
[/(?:extra|ultra)bold/u, 800],
[/(?:black|heavy)/u, 900],
[/bold/u, 700]
]
export function normalizeFontStyleName(style: string): string {
return style
.toLowerCase()
.replace(/italic|oblique/u, '')
.replace(/[^a-z0-9]+/gu, '')
}
export function parseFontStyle(style: string | undefined): ParsedFontStyle {
const raw = style ?? ''
const italic = /(?:italic|oblique)/iu.test(raw)
const normalized = normalizeFontStyleName(raw)
const numericWeight = normalized.match(/(?:^|[^0-9])([1-9]00)(?:[^0-9]|$)/u)?.[1]
if (numericWeight) return { weight: Number(numericWeight), italic }
for (const [pattern, weight] of FONT_STYLE_WEIGHTS) {
if (pattern.test(normalized)) return { weight, italic }
}
return { weight: 400, italic }
}
export function fontFaceFromFigmaFontName(fontName: {
family?: string
style?: string
postscript?: string
}): FontFaceRef {
const style = fontName.style ?? 'Regular'
return {
family: fontName.family ?? 'Inter',
style,
postscriptName: fontName.postscript,
...parseFontStyle(style)
}
}
export function fontFaceRenderFamily(family: string, style: string): string {
return `__op_font__${family.replace(/[^a-z0-9_-]+/giu, '_')}__${style.replace(/[^a-z0-9_-]+/giu, '_')}`
}

View file

@ -4,6 +4,7 @@ import { DEFAULT_FONT_FAMILY, IS_BROWSER, GOOGLE_FONTS_API_KEY } from '#core/con
import type { SceneGraph } from '#core/scene-graph'
import { fontFallbackEntry } from '#core/text/fallbacks'
import type { FontFallbackScript } from '#core/text/fallbacks'
import { fontFaceRenderFamily, parseFontStyle } from '#core/text/font-face'
export interface FontInfo {
family: string
@ -70,16 +71,7 @@ export function isVariableFont(data: ArrayBuffer): boolean {
}
export function styleToWeight(style: string): number {
const s = style.toLowerCase().replace(/[\s-_]/g, '')
if (s.includes('thin') || s.includes('hairline')) return 100
if (s.includes('extralight') || s.includes('ultralight')) return 200
if (s.includes('light')) return 300
if (s.includes('medium')) return 500
if (s.includes('semibold') || s.includes('demibold')) return 600
if (s.includes('extrabold') || s.includes('ultrabold')) return 800
if (s.includes('black') || s.includes('heavy')) return 900
if (s.includes('bold')) return 700
return 400
return parseFontStyle(style).weight
}
export function weightToStyle(weight: number, italic = false): string {
@ -103,6 +95,7 @@ export class FontManager {
private fallbackUserAgent: string | undefined
private googleFontsCache = new Map<string, Record<string, string>>()
private googleFontsFailed = new Set<string>()
private registeredRenderFamilies = new Set<string>()
private cjkFallbackFamilies: string[] = []
private cjkFallbackPromise: Promise<string[]> | null = null
private arabicFallbackFamilies: string[] = []
@ -110,6 +103,7 @@ export class FontManager {
attachProvider(_canvasKit: CanvasKit, provider: TypefaceFontProvider): void {
this.fontProvider = provider
this.registeredRenderFamilies.clear()
for (const [cacheKey, data] of this.loadedFamilies) {
const family = cacheKey.slice(0, cacheKey.indexOf('|'))
this.registerFontInCanvasKit(family, data)
@ -254,6 +248,21 @@ export class FontManager {
return this.loadedFamilies.get(`${family}|${style}`) ?? null
}
renderFamily(family: string, style: string): string {
const data = this.loadedData(family, style)
if (!data) return family
const renderFamily = fontFaceRenderFamily(family, style)
if (!this.registeredRenderFamilies.has(renderFamily)) {
if (this.registerFontInCanvasKit(renderFamily, data)) {
this.registeredRenderFamilies.add(renderFamily)
} else {
return family
}
}
return renderFamily
}
collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[string, string]> {
const fontKeys = new Set<string>()
const collect = (id: string) => {

View file

@ -31,6 +31,8 @@ describe('styleToWeight', () => {
expect(styleToWeight('Thin')).toBe(100)
expect(styleToWeight('Medium')).toBe(500)
expect(styleToWeight('SemiBold')).toBe(600)
expect(styleToWeight('Semi Bold')).toBe(600)
expect(styleToWeight('DemiBold')).toBe(600)
expect(styleToWeight('ExtraBold')).toBe(800)
expect(styleToWeight('Black')).toBe(900)
})
@ -38,6 +40,7 @@ describe('styleToWeight', () => {
test('handles italic variants', () => {
expect(styleToWeight('Bold Italic')).toBe(700)
expect(styleToWeight('Light Italic')).toBe(300)
expect(styleToWeight('600 Italic')).toBe(600)
})
test('case insensitive', () => {
@ -124,6 +127,24 @@ describe('FontManager loaded font cache', () => {
expect(manager.provider()).toBeNull()
})
test('registers loaded faces under exact render families', () => {
const manager = new FontManager()
const recording = createRecordingProvider()
manager.attachProvider({} as CanvasKit, recording.provider)
manager.markLoaded('Inter', 'SemiBold', new ArrayBuffer(12))
const renderFamily = manager.renderFamily('Inter', 'SemiBold')
expect(renderFamily).toBe('__op_font__Inter__SemiBold')
expect(recording.registrations).toEqual([
{ family: 'Inter', byteLength: 12 },
{ family: '__op_font__Inter__SemiBold', byteLength: 12 }
])
expect(manager.renderFamily('Inter', 'SemiBold')).toBe(renderFamily)
expect(recording.registrations).toHaveLength(2)
})
test('loads downloaded cache before other sources', async () => {
const manager = new FontManager()
const recording = createRecordingProvider()