From 3534a4a919fa002d966eaf1aca1e3097b33b020b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 17 May 2026 13:40:56 +0300 Subject: [PATCH] fix(canvas): render exact font faces --- packages/core/src/canvas/labels/hit-test.ts | 94 ++++++++++++++++++--- packages/core/src/canvas/text.ts | 37 +++++--- packages/core/src/index.ts | 8 ++ packages/core/src/text/font-face.ts | 61 +++++++++++++ packages/core/src/text/fonts.ts | 29 ++++--- tests/engine/text/fonts/loading.test.ts | 21 +++++ 6 files changed, 219 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/text/font-face.ts diff --git a/packages/core/src/canvas/labels/hit-test.ts b/packages/core/src/canvas/labels/hit-test.ts index e9e0dea34..c9263bf3e 100644 --- a/packages/core/src/canvas/labels/hit-test.ts +++ b/packages/core/src/canvas/labels/hit-test.ts @@ -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( graph: SceneGraph, items: readonly T[], @@ -93,6 +109,15 @@ function hitCachedLabel( + 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']) diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text.ts index 5a4e73397..b7f020b70 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text.ts @@ -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), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a6681f83..195595fbc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/text/font-face.ts b/packages/core/src/text/font-face.ts new file mode 100644 index 000000000..68153fa52 --- /dev/null +++ b/packages/core/src/text/font-face.ts @@ -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, '_')}` +} diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index f6aa6b26f..1ee77c260 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -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>() private googleFontsFailed = new Set() + private registeredRenderFamilies = new Set() private cjkFallbackFamilies: string[] = [] private cjkFallbackPromise: Promise | 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() const collect = (id: string) => { diff --git a/tests/engine/text/fonts/loading.test.ts b/tests/engine/text/fonts/loading.test.ts index 85c842501..a4bd0fa25 100644 --- a/tests/engine/text/fonts/loading.test.ts +++ b/tests/engine/text/fonts/loading.test.ts @@ -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()