Skip to content

Commit e097e51

Browse files
committed
fix(badges): use lookup table for char widths when canvas not available
1 parent 5cb6bc6 commit e097e51

File tree

1 file changed

+221
-61
lines changed

1 file changed

+221
-61
lines changed

server/api/registry/badge/[type]/[...pkg].get.ts

Lines changed: 221 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -51,75 +51,235 @@ const SHIELDS_FONT_SHORTHAND = 'normal normal 400 11px Verdana, Geneva, DejaVu S
5151

5252
let cachedCanvasContext: SKRSContext2D | null | undefined
5353

54-
const NARROW_CHARS = new Set([' ', '!', '"', "'", '(', ')', '*', ',', '-', '.', ':', ';', '|'])
55-
const MEDIUM_CHARS = new Set([
56-
'#',
57-
'$',
58-
'+',
59-
'/',
60-
'<',
61-
'=',
62-
'>',
63-
'?',
64-
'@',
65-
'[',
66-
'\\',
67-
']',
68-
'^',
69-
'_',
70-
'`',
71-
'{',
72-
'}',
73-
'~',
74-
])
75-
76-
const FALLBACK_WIDTHS = {
54+
const CHAR_WIDTHS: Record<'default' | 'shieldsio', Record<string, number>> = {
55+
/**
56+
* Manually measured widths for font locally via:
57+
*
58+
* ```ts
59+
* // Geist font widths
60+
* const ctx = createCanvas(1, 1).getContext('2d')
61+
* const chars = ' !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
62+
* ctx.font = BADGE_FONT_SHORTHAND
63+
* const entries = [...chars].map(ch => `'${ch === "'" ? "\\'" : ch === '\\' ? '\\\\' : ch}': ${Math.ceil(ctx.measureText(ch).width)}`)
64+
* console.log('default: {\n ' + entries.join(', ') + '\n}')
65+
*
66+
* // Verdana font widths
67+
* ctx.font = SHIELDS_FONT_SHORTHAND
68+
* const entries = [...chars].map(ch => `'${ch === "'" ? "\\'" : ch === '\\' ? '\\\\' : ch}': ${Math.ceil(ctx.measureText(ch).width)}`)
69+
* console.log('shieldsio: {\n ' + entries.join(', ') + '\n}')
70+
* ```
71+
*/
7772
default: {
78-
narrow: 3,
79-
medium: 5,
80-
digit: 6,
81-
uppercase: 7,
82-
other: 6,
73+
' ': 3,
74+
'!': 3,
75+
'"': 4,
76+
'#': 6,
77+
'$': 6,
78+
'%': 10,
79+
'&': 7,
80+
"'": 3,
81+
'(': 4,
82+
')': 4,
83+
'*': 5,
84+
'+': 6,
85+
',': 3,
86+
'-': 5,
87+
'.': 3,
88+
'/': 4,
89+
':': 4,
90+
';': 4,
91+
'<': 7,
92+
'=': 6,
93+
'>': 7,
94+
'?': 5,
95+
'@': 10,
96+
'[': 4,
97+
'\\': 4,
98+
']': 4,
99+
'^': 6,
100+
'_': 4,
101+
'`': 6,
102+
'{': 4,
103+
'|': 4,
104+
'}': 4,
105+
'~': 6,
106+
'0': 7,
107+
'1': 4,
108+
'2': 6,
109+
'3': 6,
110+
'4': 7,
111+
'5': 6,
112+
'6': 7,
113+
'7': 6,
114+
'8': 7,
115+
'9': 7,
116+
'A': 7,
117+
'B': 7,
118+
'C': 7,
119+
'D': 8,
120+
'E': 7,
121+
'F': 6,
122+
'G': 7,
123+
'H': 8,
124+
'I': 3,
125+
'J': 5,
126+
'K': 7,
127+
'L': 6,
128+
'M': 9,
129+
'N': 8,
130+
'O': 8,
131+
'P': 7,
132+
'Q': 8,
133+
'R': 7,
134+
'S': 6,
135+
'T': 6,
136+
'U': 8,
137+
'V': 7,
138+
'W': 10,
139+
'X': 7,
140+
'Y': 7,
141+
'Z': 6,
142+
'a': 6,
143+
'b': 6,
144+
'c': 5,
145+
'd': 6,
146+
'e': 6,
147+
'f': 4,
148+
'g': 6,
149+
'h': 6,
150+
'i': 3,
151+
'j': 3,
152+
'k': 6,
153+
'l': 3,
154+
'm': 9,
155+
'n': 6,
156+
'o': 6,
157+
'p': 6,
158+
'q': 6,
159+
'r': 4,
160+
's': 5,
161+
't': 4,
162+
'u': 6,
163+
'v': 6,
164+
'w': 9,
165+
'x': 6,
166+
'y': 6,
167+
'z': 5,
83168
},
84169
shieldsio: {
85-
narrow: 3,
86-
medium: 5,
87-
digit: 6,
88-
uppercase: 7,
89-
other: 5.5,
170+
' ': 4,
171+
'!': 5,
172+
'"': 6,
173+
'#': 9,
174+
'$': 7,
175+
'%': 12,
176+
'&': 8,
177+
"'": 3,
178+
'(': 5,
179+
')': 5,
180+
'*': 7,
181+
'+': 9,
182+
',': 4,
183+
'-': 5,
184+
'.': 4,
185+
'/': 5,
186+
':': 5,
187+
';': 5,
188+
'<': 9,
189+
'=': 9,
190+
'>': 9,
191+
'?': 6,
192+
'@': 11,
193+
'[': 5,
194+
'\\': 5,
195+
']': 5,
196+
'^': 9,
197+
'_': 7,
198+
'`': 7,
199+
'{': 7,
200+
'|': 5,
201+
'}': 7,
202+
'~': 9,
203+
'0': 7,
204+
'1': 7,
205+
'2': 7,
206+
'3': 7,
207+
'4': 7,
208+
'5': 7,
209+
'6': 7,
210+
'7': 7,
211+
'8': 7,
212+
'9': 7,
213+
'A': 8,
214+
'B': 8,
215+
'C': 8,
216+
'D': 9,
217+
'E': 7,
218+
'F': 7,
219+
'G': 9,
220+
'H': 9,
221+
'I': 5,
222+
'J': 5,
223+
'K': 8,
224+
'L': 7,
225+
'M': 10,
226+
'N': 9,
227+
'O': 9,
228+
'P': 7,
229+
'Q': 9,
230+
'R': 8,
231+
'S': 8,
232+
'T': 7,
233+
'U': 9,
234+
'V': 8,
235+
'W': 11,
236+
'X': 8,
237+
'Y': 7,
238+
'Z': 8,
239+
'a': 7,
240+
'b': 7,
241+
'c': 6,
242+
'd': 7,
243+
'e': 7,
244+
'f': 4,
245+
'g': 7,
246+
'h': 7,
247+
'i': 4,
248+
'j': 4,
249+
'k': 7,
250+
'l': 4,
251+
'm': 11,
252+
'n': 7,
253+
'o': 7,
254+
'p': 7,
255+
'q': 7,
256+
'r': 5,
257+
's': 6,
258+
't': 5,
259+
'u': 7,
260+
'v': 7,
261+
'w': 9,
262+
'x': 7,
263+
'y': 7,
264+
'z': 6,
90265
},
91-
} as const
92-
93-
function estimateTextWidth(text: string, fallbackFont: 'default' | 'shieldsio'): number {
94-
// Heuristic coefficients tuned to keep fallback rendering close to canvas metrics.
95-
const widths = FALLBACK_WIDTHS[fallbackFont]
96-
let totalWidth = 0
97-
98-
for (const character of text) {
99-
if (NARROW_CHARS.has(character)) {
100-
totalWidth += widths.narrow
101-
continue
102-
}
103-
104-
if (MEDIUM_CHARS.has(character)) {
105-
totalWidth += widths.medium
106-
continue
107-
}
108-
109-
if (/\d/.test(character)) {
110-
totalWidth += widths.digit
111-
continue
112-
}
266+
}
267+
// Fallback advance width for any character not in the lookup table above, e.g. emojis, CJK, etc.
268+
const CHAR_WIDTH_FALLBACK: Record<'default' | 'shieldsio', number> = {
269+
default: 12,
270+
shieldsio: 8,
271+
}
113272

114-
if (/[A-Z]/.test(character)) {
115-
totalWidth += widths.uppercase
116-
continue
117-
}
273+
function estimateTextWidth(text: string, font: 'default' | 'shieldsio'): number {
274+
const table = CHAR_WIDTHS[font]
275+
const fallback = CHAR_WIDTH_FALLBACK[font]
276+
let total = 0
118277

119-
totalWidth += widths.other
278+
for (const ch of text) {
279+
total += table[ch] ?? fallback
120280
}
121281

122-
return Math.max(1, Math.round(totalWidth))
282+
return Math.max(1, Math.round(total))
123283
}
124284

125285
function getCanvasContext(): SKRSContext2D | null {

0 commit comments

Comments
 (0)