Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/render/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down Expand Up @@ -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)
Expand Down
46 changes: 38 additions & 8 deletions src/render/layout.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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')
})
})
36 changes: 35 additions & 1 deletion src/render/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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. */
Expand Down
5 changes: 3 additions & 2 deletions src/render/textStyle.ts
Original file line number Diff line number Diff line change
@@ -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 あ漢'
Expand Down Expand Up @@ -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

Expand Down
Loading