From fef29d7e9f98dc2f9e1f46107c5e733db51ac742 Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 29 Aug 2026 20:52:28 +0900 Subject: [PATCH 1/4] refactor: consolidate the cause-to-message idiom into errorMessage() cause instanceof Error ? cause.message : String(cause) was repeated verbatim across 10 call sites in 4 files. Pulled into a single util/errorMessage.ts, with a small test of its own. --- src/api/client.ts | 4 ++-- src/cli/commands.ts | 11 ++++++----- src/cli/main.ts | 3 ++- src/font/autoload.ts | 13 ++++--------- src/util/errorMessage.test.ts | 14 ++++++++++++++ src/util/errorMessage.ts | 4 ++++ 6 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 src/util/errorMessage.test.ts create mode 100644 src/util/errorMessage.ts diff --git a/src/api/client.ts b/src/api/client.ts index 848444e..e7a194f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -12,6 +12,7 @@ import { import { fromMessage } from '../core/source' import type { MessageLike, MessageSourceOptions, QuoteInput } from '../core/types' import { createClient, HTTPError, type HttpClient, TimeoutError } from '../http/client' +import { errorMessage } from '../util/errorMessage' import { DEFAULT_BASE_URL, type EndpointPath, endpoints } from './endpoints' import { VoidsApiError, type VoidsOptions, type VoidsPayload, type VoidsQuoteData } from './types' @@ -212,6 +213,5 @@ function toApiError(cause: unknown, endpoint: EndpointPath, prefix: string): Voi return new VoidsApiError(`${prefix}: request timed out`, { endpoint, cause }) } - const message = cause instanceof Error ? cause.message : String(cause) - return new VoidsApiError(`${prefix}: ${message}`, { endpoint, cause }) + return new VoidsApiError(`${prefix}: ${errorMessage(cause)}`, { endpoint, cause }) } diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 7bc0f7d..37fb2c2 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -25,6 +25,7 @@ import { } from '../font/install' import { DEFAULT_FONT_FAMILIES } from '../font/sources' import { checkFontUpdates, type FontUpdateStatus } from '../font/updates' +import { errorMessage } from '../util/errorMessage' import { isNewerVersion } from '../util/version' import { checkEnv, type EnvReport } from './env' import { currentVersion } from './packageVersion' @@ -170,7 +171,7 @@ export async function uninstallCommand( info.images > 0 ? `Removed Twemoji (${info.images} images)` : 'Twemoji was not installed', ) } catch (cause) { - io.line(`✗ Twemoji — ${cause instanceof Error ? cause.message : String(cause)}`) + io.line(`✗ Twemoji — ${errorMessage(cause)}`) failed = true } } @@ -185,7 +186,7 @@ export async function uninstallCommand( : 'No fonts to remove', ) } catch (cause) { - io.line(`✗ Fonts — ${cause instanceof Error ? cause.message : String(cause)}`) + io.line(`✗ Fonts — ${errorMessage(cause)}`) failed = true } } @@ -375,7 +376,7 @@ export async function updateCommand(deps: CliDeps, io: CliIo): Promise { const result = await (deps.installTwemoji ?? installTwemoji)() io.line(` ✓ updated to ${result.version}`) } catch (cause) { - io.line(` ✗ Twemoji — ${cause instanceof Error ? cause.message : String(cause)}`) + io.line(` ✗ Twemoji — ${errorMessage(cause)}`) failed = true } } @@ -522,7 +523,7 @@ export async function renderCommand( io.line(`✓ ${outPath} (${formatBytes(bytes.length)})`) return 0 } catch (cause) { - io.line(`✗ ${cause instanceof Error ? cause.message : String(cause)}`) + io.line(`✗ ${errorMessage(cause)}`) return 1 } } @@ -588,7 +589,7 @@ async function installTwemojiStep(deps: CliDeps, io: CliIo): Promise { return true } catch (cause) { if (progressed) io.line('') - io.line(` ✗ Twemoji — ${cause instanceof Error ? cause.message : String(cause)}`) + io.line(` ✗ Twemoji — ${errorMessage(cause)}`) return false } } diff --git a/src/cli/main.ts b/src/cli/main.ts index 457553c..cbc6fb0 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import process from 'node:process' +import { errorMessage } from '../util/errorMessage' import { run } from './index' /** Progress only when the output can overwrite a line in place. */ @@ -21,7 +22,7 @@ run(process.argv.slice(2), {}, io).then( process.exitCode = code }, (cause: unknown) => { - console.error(cause instanceof Error ? cause.message : String(cause)) + console.error(errorMessage(cause)) process.exitCode = 1 }, ) diff --git a/src/font/autoload.ts b/src/font/autoload.ts index 8b46ba7..b7d4fd4 100644 --- a/src/font/autoload.ts +++ b/src/font/autoload.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { FontNotAvailableError } from '../core/errors' import type { AutoFontOptions } from '../core/types' import { createClient } from '../http/client' +import { errorMessage } from '../util/errorMessage' import { resolveFontAlias } from './catalogue' import { cachedFontPath, isCached, resolveCacheDir, writeCachedFont } from './diskCache' import { type FontFace, fileNameFor, resolveGoogleFont, slugFor } from './googleFonts' @@ -101,10 +102,7 @@ export async function useFont(requested: string, options: EnsureOptions = {}): P ready.add(family) return true } - warnOnce( - `resolve:${family}`, - `makeitaquote: ${cause instanceof Error ? cause.message : String(cause)}`, - ) + warnOnce(`resolve:${family}`, `makeitaquote: ${errorMessage(cause)}`) return false } @@ -148,10 +146,7 @@ export async function installFont( ...(options.signal ? { signal: options.signal } : {}), }) } catch (cause) { - warnOnce( - `resolve:${family}`, - `makeitaquote: ${cause instanceof Error ? cause.message : String(cause)}`, - ) + warnOnce(`resolve:${family}`, `makeitaquote: ${errorMessage(cause)}`) return false } @@ -230,7 +225,7 @@ async function ensureFace( warnOnce( `failed:${family}`, `makeitaquote: could not download ${family} ` + - `(${cause instanceof Error ? cause.message : String(cause)}). ` + + `(${errorMessage(cause)}). ` + 'Falling back to system fonts; text may render as boxes. ' + 'To fix this, register a font yourself with ' + `fonts.registerFromPath(path, family), or place the file at ${cachedFontPath(dir, fileName)}.`, diff --git a/src/util/errorMessage.test.ts b/src/util/errorMessage.test.ts new file mode 100644 index 0000000..df0e33c --- /dev/null +++ b/src/util/errorMessage.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { errorMessage } from './errorMessage' + +describe('errorMessage', () => { + it('reads the message off an Error', () => { + expect(errorMessage(new Error('boom'))).toBe('boom') + }) + + it('stringifies anything else that was thrown', () => { + expect(errorMessage('boom')).toBe('boom') + expect(errorMessage(404)).toBe('404') + expect(errorMessage(null)).toBe('null') + }) +}) diff --git a/src/util/errorMessage.ts b/src/util/errorMessage.ts new file mode 100644 index 0000000..7153e0e --- /dev/null +++ b/src/util/errorMessage.ts @@ -0,0 +1,4 @@ +/** A human-readable message for anything caught from a `try`/`catch` — not every `throw` is an `Error`. */ +export function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} From 27495fdc62c91c7cd090832dce4f5b4a3bce0aae Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 29 Aug 2026 20:56:21 +0900 Subject: [PATCH 2/4] refactor(font): dedupe the resolveFontAlias-with-fallback and quote-stripping idioms resolveFontAlias(x) ?? x was repeated at 7 call sites across autoload.ts, catalogue.ts, googleFonts.ts and install.ts; pulled into normalizeFontFamily(). part.trim().replace(/^["']|["']$/g, '') for a single CSS font-family token was duplicated identically in catalogue.ts, registry.ts and pipeline.ts; pulled into unquoteFontFamily(), both now exported from font/catalogue.ts alongside resolveFontAlias itself. Pure extraction, no behavior change. --- src/font/autoload.ts | 6 +++--- src/font/catalogue.ts | 14 ++++++++++++-- src/font/googleFonts.ts | 4 ++-- src/font/install.ts | 8 ++++---- src/font/registry.ts | 4 ++-- src/render/pipeline.ts | 4 ++-- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/font/autoload.ts b/src/font/autoload.ts index b7d4fd4..d024631 100644 --- a/src/font/autoload.ts +++ b/src/font/autoload.ts @@ -4,7 +4,7 @@ import { FontNotAvailableError } from '../core/errors' import type { AutoFontOptions } from '../core/types' import { createClient } from '../http/client' import { errorMessage } from '../util/errorMessage' -import { resolveFontAlias } from './catalogue' +import { normalizeFontFamily } from './catalogue' import { cachedFontPath, isCached, resolveCacheDir, writeCachedFont } from './diskCache' import { type FontFace, fileNameFor, resolveGoogleFont, slugFor } from './googleFonts' import { fonts } from './registry' @@ -71,7 +71,7 @@ function isOnline(options: EnsureOptions): boolean { * everything past this point agrees on the same real family. */ export async function useFont(requested: string, options: EnsureOptions = {}): Promise { - const family = resolveFontAlias(requested) ?? requested + const family = normalizeFontFamily(requested) if (ready.has(family) || fonts.has(family)) return true if (!isOnline(options)) { @@ -127,7 +127,7 @@ export async function installFont( requested: string, options: EnsureOptions = {}, ): Promise { - const family = resolveFontAlias(requested) ?? requested + const family = normalizeFontFamily(requested) const weights = options.weights ?? [400, 700] if (!isOnline(options)) { diff --git a/src/font/catalogue.ts b/src/font/catalogue.ts index 83d2fb6..90dcbb7 100644 --- a/src/font/catalogue.ts +++ b/src/font/catalogue.ts @@ -103,6 +103,16 @@ export function resolveFontAlias(input: string): string | undefined { return FONT_ALIASES[key] ?? CATALOGUE_BY_LOWERCASE.get(key) } +/** `resolveFontAlias`, falling back to `input` itself when it isn't a known alias. */ +export function normalizeFontFamily(input: string): string { + return resolveFontAlias(input) ?? input +} + +/** Trims a CSS font-family token and strips a matching pair of quotes, if any. */ +export function unquoteFontFamily(part: string): string { + return part.trim().replace(/^["']|["']$/g, '') +} + /** * Resolves every alias in a CSS-style, comma-separated font stack, so * `'pop, sans-serif'` and `'Hachi Maru Pop, sans-serif'` end up identical. @@ -114,9 +124,9 @@ export function resolveFontStack(stack: string): string { return stack .split(',') .map((part) => { - const family = part.trim().replace(/^["']|["']$/g, '') + const family = unquoteFontFamily(part) if (family.length === 0 || GENERIC_FONT_FAMILIES.has(family)) return family - return resolveFontAlias(family) ?? family + return normalizeFontFamily(family) }) .join(', ') } diff --git a/src/font/googleFonts.ts b/src/font/googleFonts.ts index 75fcb0a..7d086e4 100644 --- a/src/font/googleFonts.ts +++ b/src/font/googleFonts.ts @@ -1,6 +1,6 @@ import { AssetFetchError } from '../core/errors' import { createClient } from '../http/client' -import { resolveFontAlias, suggestionFor, unavailableReason } from './catalogue' +import { normalizeFontFamily, suggestionFor, unavailableReason } from './catalogue' const CSS_ENDPOINT = 'https://fonts.googleapis.com/css2' @@ -42,7 +42,7 @@ export async function resolveGoogleFont( requested: string, options: ResolveOptions = {}, ): Promise { - const family = resolveFontAlias(requested) ?? requested + const family = normalizeFontFamily(requested) const weights = normalizeWeights(options.weights) const url = buildCssUrl(family, weights, options.italic ?? false) diff --git a/src/font/install.ts b/src/font/install.ts index 2bf9bd0..e5e3325 100644 --- a/src/font/install.ts +++ b/src/font/install.ts @@ -3,7 +3,7 @@ import { rm } from 'node:fs/promises' import { join } from 'node:path' import { isNewerVersion } from '../util/version' import { type EnsureOptions, installFont } from './autoload' -import { resolveFontAlias } from './catalogue' +import { normalizeFontFamily } from './catalogue' import { resolveCacheDir } from './diskCache' import { slugFor } from './googleFonts' @@ -45,7 +45,7 @@ export async function installFonts( ): Promise { const results: FontInstallResult[] = [] for (const requested of families) { - const family = resolveFontAlias(requested) ?? requested + const family = normalizeFontFamily(requested) results.push({ family, ok: await installFont(family, options) }) } return results @@ -76,7 +76,7 @@ export async function uninstallFonts( let removed = 0 for (const requested of families) { - const family = resolveFontAlias(requested) ?? requested + const family = normalizeFontFamily(requested) const prefix = `${slugFor(family)}-` let names: string[] try { @@ -125,7 +125,7 @@ export async function pruneFonts( } const wanted = families - ? new Set(families.map((family) => slugFor(resolveFontAlias(family) ?? family))) + ? new Set(families.map((family) => slugFor(normalizeFontFamily(family)))) : null interface File { diff --git a/src/font/registry.ts b/src/font/registry.ts index 1cd9154..d6091ed 100644 --- a/src/font/registry.ts +++ b/src/font/registry.ts @@ -1,7 +1,7 @@ import { readdirSync, statSync } from 'node:fs' import { extname, join } from 'node:path' import { GlobalFonts } from '../render/canvasFactory' -import { GENERIC_FONT_FAMILIES } from './catalogue' +import { GENERIC_FONT_FAMILIES, unquoteFontFamily } from './catalogue' const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.ttc', '.woff', '.woff2']) @@ -118,7 +118,7 @@ export const fonts = { */ export function resolveFamily(request: string): string | null { for (const part of request.split(',')) { - const family = part.trim().replace(/^["']|["']$/g, '') + const family = unquoteFontFamily(part) if (family.length === 0) continue if (GENERIC_FONT_FAMILIES.has(family)) return family if (fonts.has(family)) return family diff --git a/src/render/pipeline.ts b/src/render/pipeline.ts index 48bf6ba..0082371 100644 --- a/src/render/pipeline.ts +++ b/src/render/pipeline.ts @@ -2,7 +2,7 @@ import { assertRenderable, effectiveDisplayName } from '../core/quote' import type { MiQOptions, QuoteData, Segment } from '../core/types' import { type EmojiImages, prefetchEmoji } from '../emoji/loader' import { ensureDefaultFonts, reportMissingFonts, useFont } from '../font/autoload' -import { GENERIC_FONT_FAMILIES } from '../font/catalogue' +import { GENERIC_FONT_FAMILIES, unquoteFontFamily } from '../font/catalogue' import { fonts, resolveFamily } from '../font/registry' import { DEFAULT_FONT_FAMILIES, FALLBACK_FAMILY } from '../font/sources' import { alignedX, type DrawLineOptions, drawLine, drawnLineWidth } from '../text/draw' @@ -141,7 +141,7 @@ async function ensureStack(request: string, options: object): Promise { function candidateFamilies(request: string): string[] { return request .split(',') - .map((part) => part.trim().replace(/^["']|["']$/g, '')) + .map(unquoteFontFamily) .filter((family) => family.length > 0 && !GENERIC_FONT_FAMILIES.has(family)) } From 8542604d7689b8c05c3d46ccdf5393cabebae299 Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 29 Aug 2026 21:00:38 +0900 Subject: [PATCH 3/4] refactor: share the catalogue-to-alias-map pattern between fonts and themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit font/catalogue.ts's FONT_ALIASES and theme/colorThemes.ts's COLOR_THEME_ALIASES/CUSTOM_COLOR_THEME_ALIASES built the same alias -> key record from a list of {key, alias} rows — one as an inline IIFE, the other as its own aliasMap() function. Likewise CATALOGUE_BY_LOWERCASE and KEYS_BY_NORMALIZED both built a Map from the same kind of rows, just with different normalize functions. Pulled both into util/aliasCatalogue.ts as buildAliasMap/buildNormalizedKeyMap, taking accessor functions so each caller's row shape and normalization stay exactly as they were. resolveFontAlias() and resolveColorTheme() themselves are untouched — colorThemes.ts's resolveColorTheme() normalizes an alias lookup differently than a key lookup (stripping underscores only for the latter), a real difference this doesn't collapse, just the identical map-building boilerplate around it. --- src/font/catalogue.ts | 19 ++++++++++--------- src/theme/colorThemes.ts | 27 ++++++++++++++------------- src/util/aliasCatalogue.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 22 deletions(-) create mode 100644 src/util/aliasCatalogue.ts diff --git a/src/font/catalogue.ts b/src/font/catalogue.ts index 90dcbb7..fd6fa68 100644 --- a/src/font/catalogue.ts +++ b/src/font/catalogue.ts @@ -1,4 +1,5 @@ import { distance } from 'fastest-levenshtein' +import { buildAliasMap, buildNormalizedKeyMap } from '../util/aliasCatalogue' /** * Fonts this package can fetch by name, one row each — the single source @@ -80,16 +81,16 @@ export function isCatalogued(family: string): boolean { * Keys are lower-cased; use `resolveFontAlias()` rather than indexing this * directly if the input isn't already normalized. */ -export const FONT_ALIASES: Readonly> = (() => { - const aliases: Record = {} - for (const entry of FONTS) { - if (entry.alias !== null) aliases[entry.alias] = entry.family - } - return aliases -})() +export const FONT_ALIASES: Readonly> = buildAliasMap( + FONTS, + (entry) => entry.family, + (entry) => entry.alias, +) -const CATALOGUE_BY_LOWERCASE = new Map( - FONT_CATALOGUE.map((family) => [family.toLowerCase(), family]), +const CATALOGUE_BY_LOWERCASE = buildNormalizedKeyMap( + FONTS, + (entry) => entry.family, + (s) => s.trim().toLowerCase(), ) /** diff --git a/src/theme/colorThemes.ts b/src/theme/colorThemes.ts index e5828b1..d09e678 100644 --- a/src/theme/colorThemes.ts +++ b/src/theme/colorThemes.ts @@ -1,3 +1,4 @@ +import { buildAliasMap, buildNormalizedKeyMap } from '../util/aliasCatalogue' import type { ColorInput } from './color' import type { BackgroundGradientTheme } from './types' @@ -322,20 +323,22 @@ export const ALL_COLOR_THEME_CATALOGUE: readonly CataloguedColorTheme[] = [ ...CUSTOM_COLOR_THEME_CATALOGUE, ] -function aliasMap(themes: readonly ColorThemeRow[]): Readonly> { - const aliases: Record = {} - for (const entry of themes) { - if (entry.alias !== null) aliases[entry.alias] = entry.key - } - return aliases -} +const aliasOf = (entry: ColorThemeRow) => entry.alias +const keyOf = (entry: ColorThemeRow) => entry.key /** Short option names for `COLOR_THEME_CATALOGUE`, the same relationship `FONT_ALIASES` has to `FONT_CATALOGUE`. */ -export const COLOR_THEME_ALIASES: Readonly> = aliasMap(OFFICIAL_COLOR_THEMES) +export const COLOR_THEME_ALIASES: Readonly> = buildAliasMap( + OFFICIAL_COLOR_THEMES, + keyOf, + aliasOf, +) /** Short option names for `CUSTOM_COLOR_THEME_CATALOGUE`. */ -export const CUSTOM_COLOR_THEME_ALIASES: Readonly> = - aliasMap(CUSTOM_COLOR_THEMES) +export const CUSTOM_COLOR_THEME_ALIASES: Readonly> = buildAliasMap( + CUSTOM_COLOR_THEMES, + keyOf, + aliasOf, +) /** Both alias tables merged — official first, so a name collision would favor it (there are none today). */ export const ALL_COLOR_THEME_ALIASES: Readonly> = { @@ -345,9 +348,7 @@ export const ALL_COLOR_THEME_ALIASES: Readonly> = { const ALL_COLOR_THEMES = [...OFFICIAL_COLOR_THEMES, ...CUSTOM_COLOR_THEMES] -const KEYS_BY_NORMALIZED = new Map( - ALL_COLOR_THEMES.map((entry) => [normalize(entry.key), entry.key]), -) +const KEYS_BY_NORMALIZED = buildNormalizedKeyMap(ALL_COLOR_THEMES, keyOf, normalize) const BY_KEY = new Map( ALL_COLOR_THEME_CATALOGUE.map((entry) => [entry.key, entry]), diff --git a/src/util/aliasCatalogue.ts b/src/util/aliasCatalogue.ts new file mode 100644 index 0000000..77d041f --- /dev/null +++ b/src/util/aliasCatalogue.ts @@ -0,0 +1,32 @@ +/** + * Shared shape behind `font/catalogue.ts`'s `FONTS` and `theme/colorThemes.ts`'s + * `OFFICIAL_COLOR_THEMES`/`CUSTOM_COLOR_THEMES`: a fixed list of named rows, + * each with an optional short alias, that needs both an alias -> key lookup + * and a "the key itself, tolerant of case/formatting" lookup. + */ + +/** Alias → canonical key, for rows that have a short option name. */ +export function buildAliasMap( + rows: readonly Row[], + getKey: (row: Row) => T, + getAlias: (row: Row) => string | null, +): Readonly> { + const aliases: Record = {} + for (const row of rows) { + const alias = getAlias(row) + if (alias !== null) aliases[alias] = getKey(row) + } + return aliases +} + +/** + * Every row's own key, keyed by `normalize(key)` — the "exact name" half of + * a resolver, alongside `buildAliasMap`'s "short name" half. + */ +export function buildNormalizedKeyMap( + rows: readonly Row[], + getKey: (row: Row) => T, + normalize: (token: string) => string, +): Map { + return new Map(rows.map((row) => [normalize(getKey(row)), getKey(row)])) +} From ac53b334fc4d3ef81d48f1c57526cdf5161b0d4b Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Sat, 29 Aug 2026 21:03:00 +0900 Subject: [PATCH 4/4] refactor(render): share drawAttribution's displayName/username drawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both branches of drawAttribution did the same five steps on a LabelTheme (displayName and username share that exact type) — resolve the pixel size, set font and fill style, draw the prefixed text centred, advance y by the size — differing only in which theme field and which text. Pulled into drawAttributionLine(), which returns the y its own baseline landed on so displayName's caller can still add the extra gap before username. Pixel-comparing pipeline tests (draws something in the text area, etc.) all still pass, confirming identical rendered output. --- src/render/pipeline.ts | 55 ++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/render/pipeline.ts b/src/render/pipeline.ts index 0082371..8d2a597 100644 --- a/src/render/pipeline.ts +++ b/src/render/pipeline.ts @@ -11,7 +11,7 @@ import { memoizeMeasurer } from '../text/measure' import { resolveEmojiSegments, segmentText } from '../text/segment' import { isTransparent, parseColor, toCSS } from '../theme/color' import { toPixels } from '../theme/resolve' -import type { FontWeight, Theme } from '../theme/types' +import type { FontWeight, LabelTheme, Theme } from '../theme/types' import { avatarBox, loadAvatar } from './avatar' import { drawAvatarWithFade, drawBackground, loadBackgroundImage } from './background' import { type Canvas, createCanvas, type SKRSContext2D } from './canvasFactory' @@ -302,6 +302,35 @@ function drawDivider(ctx: SKRSContext2D, theme: Theme, layout: Layout, top: numb return y + thickness + gap } +/** + * Draws one centred, prefixed attribution line (display name or username) — + * both are a `LabelTheme`, styled and positioned identically — and returns + * the y position its own text baseline landed on, for the next line to + * stack under. + */ +function drawAttributionLine( + ctx: SKRSContext2D, + text: string, + style: LabelTheme, + field: string, + centreX: number, + y: number, + height: number, +): number { + const size = toPixels(style.size, height) + ctx.font = font(style.weight, size, style.font) + ctx.fillStyle = toCSS(parseColor(style.color, field)) + const baseline = y + size + fillText( + ctx, + `${style.prefix}${text}`, + centreX, + baseline, + syntheticBoldWidth(ctx, style.weight, familyFor(style.font), size), + ) + return baseline +} + function drawAttribution( ctx: SKRSContext2D, data: QuoteData, @@ -316,31 +345,27 @@ function drawAttribution( const displayName = effectiveDisplayName(data) if (displayName && !invisible(theme.displayName.color, 'theme.displayName.color')) { - const size = toPixels(theme.displayName.size, theme.height) - ctx.font = font(theme.displayName.weight, size, theme.displayName.font) - ctx.fillStyle = toCSS(parseColor(theme.displayName.color, 'theme.displayName.color')) - y += size - fillText( + y = drawAttributionLine( ctx, - `${theme.displayName.prefix}${displayName}`, + displayName, + theme.displayName, + 'theme.displayName.color', layout.centreX, y, - syntheticBoldWidth(ctx, theme.displayName.weight, familyFor(theme.displayName.font), size), + theme.height, ) y += theme.height * 0.012 } if (data.username && !invisible(theme.username.color, 'theme.username.color')) { - const size = toPixels(theme.username.size, theme.height) - ctx.font = font(theme.username.weight, size, theme.username.font) - ctx.fillStyle = toCSS(parseColor(theme.username.color, 'theme.username.color')) - y += size - fillText( + drawAttributionLine( ctx, - `${theme.username.prefix}${data.username}`, + data.username, + theme.username, + 'theme.username.color', layout.centreX, y, - syntheticBoldWidth(ctx, theme.username.weight, familyFor(theme.username.font), size), + theme.height, ) } }