From e097e5116d825aa5a4d1f95eaf4cee0826b4a5b5 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 14:00:52 +0200 Subject: [PATCH] fix(badges): use lookup table for char widths when canvas not available --- .../api/registry/badge/[type]/[...pkg].get.ts | 282 ++++++++++++++---- 1 file changed, 221 insertions(+), 61 deletions(-) diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts index aa6b0c4b10..6eaf46be3f 100644 --- a/server/api/registry/badge/[type]/[...pkg].get.ts +++ b/server/api/registry/badge/[type]/[...pkg].get.ts @@ -51,75 +51,235 @@ const SHIELDS_FONT_SHORTHAND = 'normal normal 400 11px Verdana, Geneva, DejaVu S let cachedCanvasContext: SKRSContext2D | null | undefined -const NARROW_CHARS = new Set([' ', '!', '"', "'", '(', ')', '*', ',', '-', '.', ':', ';', '|']) -const MEDIUM_CHARS = new Set([ - '#', - '$', - '+', - '/', - '<', - '=', - '>', - '?', - '@', - '[', - '\\', - ']', - '^', - '_', - '`', - '{', - '}', - '~', -]) - -const FALLBACK_WIDTHS = { +const CHAR_WIDTHS: Record<'default' | 'shieldsio', Record> = { + /** + * Manually measured widths for font locally via: + * + * ```ts + * // Geist font widths + * const ctx = createCanvas(1, 1).getContext('2d') + * const chars = ' !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + * ctx.font = BADGE_FONT_SHORTHAND + * const entries = [...chars].map(ch => `'${ch === "'" ? "\\'" : ch === '\\' ? '\\\\' : ch}': ${Math.ceil(ctx.measureText(ch).width)}`) + * console.log('default: {\n ' + entries.join(', ') + '\n}') + * + * // Verdana font widths + * ctx.font = SHIELDS_FONT_SHORTHAND + * const entries = [...chars].map(ch => `'${ch === "'" ? "\\'" : ch === '\\' ? '\\\\' : ch}': ${Math.ceil(ctx.measureText(ch).width)}`) + * console.log('shieldsio: {\n ' + entries.join(', ') + '\n}') + * ``` + */ default: { - narrow: 3, - medium: 5, - digit: 6, - uppercase: 7, - other: 6, + ' ': 3, + '!': 3, + '"': 4, + '#': 6, + '$': 6, + '%': 10, + '&': 7, + "'": 3, + '(': 4, + ')': 4, + '*': 5, + '+': 6, + ',': 3, + '-': 5, + '.': 3, + '/': 4, + ':': 4, + ';': 4, + '<': 7, + '=': 6, + '>': 7, + '?': 5, + '@': 10, + '[': 4, + '\\': 4, + ']': 4, + '^': 6, + '_': 4, + '`': 6, + '{': 4, + '|': 4, + '}': 4, + '~': 6, + '0': 7, + '1': 4, + '2': 6, + '3': 6, + '4': 7, + '5': 6, + '6': 7, + '7': 6, + '8': 7, + '9': 7, + 'A': 7, + 'B': 7, + 'C': 7, + 'D': 8, + 'E': 7, + 'F': 6, + 'G': 7, + 'H': 8, + 'I': 3, + 'J': 5, + 'K': 7, + 'L': 6, + 'M': 9, + 'N': 8, + 'O': 8, + 'P': 7, + 'Q': 8, + 'R': 7, + 'S': 6, + 'T': 6, + 'U': 8, + 'V': 7, + 'W': 10, + 'X': 7, + 'Y': 7, + 'Z': 6, + 'a': 6, + 'b': 6, + 'c': 5, + 'd': 6, + 'e': 6, + 'f': 4, + 'g': 6, + 'h': 6, + 'i': 3, + 'j': 3, + 'k': 6, + 'l': 3, + 'm': 9, + 'n': 6, + 'o': 6, + 'p': 6, + 'q': 6, + 'r': 4, + 's': 5, + 't': 4, + 'u': 6, + 'v': 6, + 'w': 9, + 'x': 6, + 'y': 6, + 'z': 5, }, shieldsio: { - narrow: 3, - medium: 5, - digit: 6, - uppercase: 7, - other: 5.5, + ' ': 4, + '!': 5, + '"': 6, + '#': 9, + '$': 7, + '%': 12, + '&': 8, + "'": 3, + '(': 5, + ')': 5, + '*': 7, + '+': 9, + ',': 4, + '-': 5, + '.': 4, + '/': 5, + ':': 5, + ';': 5, + '<': 9, + '=': 9, + '>': 9, + '?': 6, + '@': 11, + '[': 5, + '\\': 5, + ']': 5, + '^': 9, + '_': 7, + '`': 7, + '{': 7, + '|': 5, + '}': 7, + '~': 9, + '0': 7, + '1': 7, + '2': 7, + '3': 7, + '4': 7, + '5': 7, + '6': 7, + '7': 7, + '8': 7, + '9': 7, + 'A': 8, + 'B': 8, + 'C': 8, + 'D': 9, + 'E': 7, + 'F': 7, + 'G': 9, + 'H': 9, + 'I': 5, + 'J': 5, + 'K': 8, + 'L': 7, + 'M': 10, + 'N': 9, + 'O': 9, + 'P': 7, + 'Q': 9, + 'R': 8, + 'S': 8, + 'T': 7, + 'U': 9, + 'V': 8, + 'W': 11, + 'X': 8, + 'Y': 7, + 'Z': 8, + 'a': 7, + 'b': 7, + 'c': 6, + 'd': 7, + 'e': 7, + 'f': 4, + 'g': 7, + 'h': 7, + 'i': 4, + 'j': 4, + 'k': 7, + 'l': 4, + 'm': 11, + 'n': 7, + 'o': 7, + 'p': 7, + 'q': 7, + 'r': 5, + 's': 6, + 't': 5, + 'u': 7, + 'v': 7, + 'w': 9, + 'x': 7, + 'y': 7, + 'z': 6, }, -} as const - -function estimateTextWidth(text: string, fallbackFont: 'default' | 'shieldsio'): number { - // Heuristic coefficients tuned to keep fallback rendering close to canvas metrics. - const widths = FALLBACK_WIDTHS[fallbackFont] - let totalWidth = 0 - - for (const character of text) { - if (NARROW_CHARS.has(character)) { - totalWidth += widths.narrow - continue - } - - if (MEDIUM_CHARS.has(character)) { - totalWidth += widths.medium - continue - } - - if (/\d/.test(character)) { - totalWidth += widths.digit - continue - } +} +// Fallback advance width for any character not in the lookup table above, e.g. emojis, CJK, etc. +const CHAR_WIDTH_FALLBACK: Record<'default' | 'shieldsio', number> = { + default: 12, + shieldsio: 8, +} - if (/[A-Z]/.test(character)) { - totalWidth += widths.uppercase - continue - } +function estimateTextWidth(text: string, font: 'default' | 'shieldsio'): number { + const table = CHAR_WIDTHS[font] + const fallback = CHAR_WIDTH_FALLBACK[font] + let total = 0 - totalWidth += widths.other + for (const ch of text) { + total += table[ch] ?? fallback } - return Math.max(1, Math.round(totalWidth)) + return Math.max(1, Math.round(total)) } function getCanvasContext(): SKRSContext2D | null {