fix(canvas): render exact font faces
This commit is contained in:
parent
f9034d4492
commit
3534a4a919
|
|
@ -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'])
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
61
packages/core/src/text/font-face.ts
Normal file
61
packages/core/src/text/font-face.ts
Normal 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, '_')}`
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue