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
4 changes: 2 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 })
}
11 changes: 6 additions & 5 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -402,7 +403,7 @@ export async function updateCommand(deps: CliDeps, io: CliIo): Promise<number> {
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
}
}
Expand Down Expand Up @@ -544,7 +545,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
}
}
Expand Down Expand Up @@ -610,7 +611,7 @@ async function installTwemojiStep(deps: CliDeps, io: CliIo): Promise<boolean> {
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
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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
},
)
19 changes: 7 additions & 12 deletions src/font/autoload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { join } from 'node:path'
import { FontNotAvailableError } from '../core/errors'
import type { AutoFontOptions } from '../core/types'
import { createClient } from '../http/client'
import { resolveFontAlias } from './catalogue'
import { errorMessage } from '../util/errorMessage'
import { normalizeFontFamily } from './catalogue'
import { cachedFontPath, isCached, resolveCacheDir, writeCachedFont } from './diskCache'
import { type FontFace, fileNameFor, resolveGoogleFont, slugFor } from './googleFonts'
import { fonts } from './registry'
Expand Down Expand Up @@ -70,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<boolean> {
const family = resolveFontAlias(requested) ?? requested
const family = normalizeFontFamily(requested)
if (ready.has(family) || fonts.has(family)) return true

if (!isOnline(options)) {
Expand Down Expand Up @@ -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
}

Expand All @@ -129,7 +127,7 @@ export async function installFont(
requested: string,
options: EnsureOptions = {},
): Promise<boolean> {
const family = resolveFontAlias(requested) ?? requested
const family = normalizeFontFamily(requested)
const weights = options.weights ?? [400, 700]

if (!isOnline(options)) {
Expand All @@ -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
}

Expand Down Expand Up @@ -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)}.`,
Expand Down
33 changes: 22 additions & 11 deletions src/font/catalogue.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Record<string, CataloguedFont>> = (() => {
const aliases: Record<string, CataloguedFont> = {}
for (const entry of FONTS) {
if (entry.alias !== null) aliases[entry.alias] = entry.family
}
return aliases
})()
export const FONT_ALIASES: Readonly<Record<string, CataloguedFont>> = buildAliasMap(
FONTS,
(entry) => entry.family,
(entry) => entry.alias,
)

const CATALOGUE_BY_LOWERCASE = new Map<string, CataloguedFont>(
FONT_CATALOGUE.map((family) => [family.toLowerCase(), family]),
const CATALOGUE_BY_LOWERCASE = buildNormalizedKeyMap(
FONTS,
(entry) => entry.family,
(s) => s.trim().toLowerCase(),
)

/**
Expand All @@ -103,6 +104,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.
Expand All @@ -114,9 +125,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(', ')
}
Expand Down
4 changes: 2 additions & 2 deletions src/font/googleFonts.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -42,7 +42,7 @@ export async function resolveGoogleFont(
requested: string,
options: ResolveOptions = {},
): Promise<FontFace[]> {
const family = resolveFontAlias(requested) ?? requested
const family = normalizeFontFamily(requested)
const weights = normalizeWeights(options.weights)
const url = buildCssUrl(family, weights, options.italic ?? false)

Expand Down
8 changes: 4 additions & 4 deletions src/font/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -45,7 +45,7 @@ export async function installFonts(
): Promise<FontInstallResult[]> {
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/font/registry.ts
Original file line number Diff line number Diff line change
@@ -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'])

Expand Down Expand Up @@ -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
Expand Down
59 changes: 42 additions & 17 deletions src/render/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -141,7 +141,7 @@ async function ensureStack(request: string, options: object): Promise<void> {
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))
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
}
}
Expand Down
Loading
Loading