From f679038fd2c550980f079adac4b0025606211e3d Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 24 Oct 2026 23:30:06 +0900 Subject: [PATCH] fix: quote font family names so @napi-rs/canvas 1.0.12+ accepts ctx.font @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 keeps the previous font. Every quote then rendered as tiny tofu in the 10px default font. Quote every non-generic family wherever a font shorthand is built, and cap @napi-rs/canvas below 1.0.12 until the other text layout changes in that release have been checked against the visual gallery. --- package-lock.json | 2 +- package.json | 2 +- src/render/avatar.ts | 3 ++- src/render/layout.test.ts | 46 ++++++++++++++++++++++++++++++++------- src/render/layout.ts | 36 +++++++++++++++++++++++++++++- src/render/textStyle.ts | 5 +++-- 6 files changed, 80 insertions(+), 14 deletions(-) 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