diff --git a/package-lock.json b/package-lock.json index 47d1a7a..63c6e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@makeitaquote/utils": "^1.2.1", - "@napi-rs/canvas": "^1.0.8", + "@napi-rs/canvas": ">=1.0.8 <1.0.12", "@twemoji/parser": "^17.0.2", "budoux": "^0.9.1", "cleye": "^2.6.0", diff --git a/package.json b/package.json index 3a75b76..61fee7e 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ }, "dependencies": { "@makeitaquote/utils": "^1.2.1", - "@napi-rs/canvas": "^1.0.8", + "@napi-rs/canvas": ">=1.0.8 <1.0.12", "@twemoji/parser": "^17.0.2", "budoux": "^0.9.1", "cleye": "^2.6.0", diff --git a/src/render/avatar.ts b/src/render/avatar.ts index f6ecb77..ca8a41e 100644 --- a/src/render/avatar.ts +++ b/src/render/avatar.ts @@ -6,6 +6,7 @@ import type { AvatarTheme } from '../theme/types' import type { AssetCache } from '../util/assetCache' import { avatarCache } from './avatarCache' import { createCanvas, type Image, loadImage, type SKRSContext2D } from './canvasFactory' +import { fontString } from './layout' const http = createClient({ timeout: 15_000, retry: 2 }) @@ -261,7 +262,7 @@ export function drawAvatar( if (initial) { const size = Math.min(box.width, box.height) * 0.22 ctx.fillStyle = toCSS(parseColor(theme.fallback.color, 'theme.avatar.fallback.color')) - ctx.font = `${size}px ${options.fallbackFont ?? 'sans-serif'}` + ctx.font = fontString('normal', size, options.fallbackFont ?? 'sans-serif') ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(initial.toUpperCase(), box.x + box.width / 2, box.y + box.height / 2) diff --git a/src/render/layout.test.ts b/src/render/layout.test.ts index 98e86db..54327c1 100644 --- a/src/render/layout.test.ts +++ b/src/render/layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { defineTheme } from '../theme/resolve' -import { autoArea, computeLayout, fontString, gradientLine } from './layout' +import { autoArea, computeLayout, cssFontFamily, fontString, gradientLine } from './layout' describe('computeLayout · side', () => { it('puts the avatar on the left by default', () => { @@ -139,30 +139,60 @@ describe('gradientLine', () => { describe('fontString', () => { it('omits a normal weight', () => { - expect(fontString('normal', 32, 'Noto Sans JP')).toBe('32px Noto Sans JP') + expect(fontString('normal', 32, 'Noto Sans JP')).toBe('32px "Noto Sans JP"') }) it('includes a keyword weight', () => { - expect(fontString('bold', 32, 'Noto Sans JP')).toBe('bold 32px Noto Sans JP') + expect(fontString('bold', 32, 'Noto Sans JP')).toBe('bold 32px "Noto Sans JP"') }) it('includes a numeric weight', () => { - expect(fontString(700, 32, 'Noto Sans JP')).toBe('700 32px Noto Sans JP') + expect(fontString(700, 32, 'Noto Sans JP')).toBe('700 32px "Noto Sans JP"') }) it('omits italic by default', () => { - expect(fontString('normal', 32, 'Noto Sans JP')).toBe('32px Noto Sans JP') + expect(fontString('normal', 32, 'Noto Sans JP')).toBe('32px "Noto Sans JP"') }) it('prefixes italic before the weight, in CSS shorthand order', () => { - expect(fontString('bold', 32, 'Noto Sans JP', true)).toBe('italic bold 32px Noto Sans JP') + expect(fontString('bold', 32, 'Noto Sans JP', true)).toBe('italic bold 32px "Noto Sans JP"') }) it('prefixes italic even with a normal weight', () => { - expect(fontString('normal', 32, 'Noto Sans JP', true)).toBe('italic 32px Noto Sans JP') + expect(fontString('normal', 32, 'Noto Sans JP', true)).toBe('italic 32px "Noto Sans JP"') }) it('prefixes italic before a numeric weight', () => { - expect(fontString(700, 32, 'Noto Sans JP', true)).toBe('italic 700 32px Noto Sans JP') + expect(fontString(700, 32, 'Noto Sans JP', true)).toBe('italic 700 32px "Noto Sans JP"') + }) + + it('quotes every family in a stack', () => { + expect(fontString('normal', 32, 'M PLUS Rounded 1c, Noto Sans JP, sans-serif')).toBe( + '32px "M PLUS Rounded 1c", "Noto Sans JP", sans-serif', + ) + }) +}) + +describe('cssFontFamily', () => { + it('quotes a family that is not a valid CSS identifier', () => { + expect(cssFontFamily('M PLUS Rounded 1c')).toBe('"M PLUS Rounded 1c"') + }) + + it('leaves generic keywords unquoted, in any case', () => { + expect(cssFontFamily('serif, Sans-Serif, monospace, system-ui')).toBe( + 'serif, Sans-Serif, monospace, system-ui', + ) + }) + + it('keeps already-quoted families as written, commas included', () => { + expect(cssFontFamily(`"Foo, Bar", 'Baz', Qux`)).toBe(`"Foo, Bar", 'Baz', "Qux"`) + }) + + it('escapes quotes and backslashes inside a family', () => { + expect(cssFontFamily('A"B\\C')).toBe('"A\\"B\\\\C"') + }) + + it('drops empty entries and surrounding whitespace', () => { + expect(cssFontFamily(' Noto Sans JP ,, sans-serif ')).toBe('"Noto Sans JP", sans-serif') }) }) diff --git a/src/render/layout.ts b/src/render/layout.ts index d36c230..beb1ebc 100644 --- a/src/render/layout.ts +++ b/src/render/layout.ts @@ -116,6 +116,40 @@ export function watermarkCorner(theme: Theme): 'bottom-right' | 'bottom-left' | return theme.avatar.position === 'right' ? 'bottom-left' : 'bottom-right' } +/** CSS generic family keywords, which name no real font and so must stay unquoted. */ +const GENERIC_FAMILIES = new Set([ + 'serif', + 'sans-serif', + 'monospace', + 'cursive', + 'fantasy', + 'system-ui', + 'emoji', + 'math', + 'fangsong', + 'ui-serif', + 'ui-sans-serif', + 'ui-monospace', + 'ui-rounded', +]) + +/** + * Quotes every family in a comma-separated list, leaving generic keywords and already-quoted names alone. + * + * `@napi-rs/canvas` 1.0.12+ parses `ctx.font` like Blink, which rejects the whole value when an unquoted family is not a valid CSS identifier sequence (the `1c` in `M PLUS Rounded 1c`) and silently keeps the previous font instead. + */ +export function cssFontFamily(families: string): string { + return (families.match(/"[^"]*"|'[^']*'|[^,]+/g) ?? []) + .map((family) => family.trim()) + .filter((family) => family !== '') + .map((family) => + /^["']/.test(family) || GENERIC_FAMILIES.has(family.toLowerCase()) + ? family + : `"${family.replace(/["\\]/g, '\\$&')}"`, + ) + .join(', ') +} + /** * Builds the CSS font shorthand a canvas context expects. * @@ -129,7 +163,7 @@ export function fontString( ): string { const stylePrefix = italic ? 'italic ' : '' const weightPart = weight === 'normal' ? '' : `${weight} ` - return `${stylePrefix}${weightPart}${size}px ${family}` + return `${stylePrefix}${weightPart}${size}px ${cssFontFamily(family)}` } /** Resolves a size that may be a fraction of the canvas height, or pixels. */ diff --git a/src/render/textStyle.ts b/src/render/textStyle.ts index 9353ace..c47c5ac 100644 --- a/src/render/textStyle.ts +++ b/src/render/textStyle.ts @@ -1,5 +1,6 @@ import type { FontWeight } from '../theme/types' import type { SKRSContext2D } from './canvasFactory' +import { fontString } from './layout' /** Mixed script, so the probe catches fonts that only vary in one of them. */ const PROBE = 'AWMil あ漢' @@ -32,9 +33,9 @@ function detect(ctx: SKRSContext2D, family: string): boolean { if (cached !== undefined) return cached const previous = ctx.font - ctx.font = `100px ${family}` + ctx.font = fontString('normal', 100, family) const regular = ctx.measureText(PROBE).width - ctx.font = `bold 100px ${family}` + ctx.font = fontString('bold', 100, family) const bold = ctx.measureText(PROBE).width ctx.font = previous