From a52779d1cde17a7ffc817b7825cf1d5b31c5594e Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 14 Feb 2026 00:53:07 +0100 Subject: [PATCH 1/2] fix: improve badge text measurement accuracy This PR improves badge appearance by using a more accurate method to measure text width. Instead of relying on a predefined character width, it utilizes the Canvas API to measure the actual width of the text, resulting in better alignment and spacing for badges with varying character widths. Speaking in code, this is how the old and new methods compare: { label: 'iiiiiiiiii', leftWidthOld: 86, leftWidth: 41 } { label: 'wwwwwwwwww', leftWidthOld: 86, leftWidth: 97 } --- package.json | 1 + pnpm-lock.yaml | 125 ++++++++++++++++++ .../api/registry/badge/[type]/[...pkg].get.ts | 52 ++++++-- 3 files changed, 167 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 804a4dcb3..73d59e6f5 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@iconify-json/vscode-icons": "1.2.40", "@intlify/shared": "11.2.8", "@lunariajs/core": "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3", + "@napi-rs/canvas": "0.1.92", "@nuxt/a11y": "1.0.0-alpha.1", "@nuxt/fonts": "0.13.0", "@nuxt/scripts": "0.13.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd7800b3b..7f9666e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@lunariajs/core': specifier: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3 version: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3 + '@napi-rs/canvas': + specifier: 0.1.92 + version: 0.1.92 '@nuxt/a11y': specifier: 1.0.0-alpha.1 version: 1.0.0-alpha.1(magicast@0.5.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) @@ -1933,6 +1936,81 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/canvas-android-arm64@0.1.92': + resolution: {integrity: sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.92': + resolution: {integrity: sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.92': + resolution: {integrity: sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92': + resolution: {integrity: sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.92': + resolution: {integrity: sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.92': + resolution: {integrity: sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.92': + resolution: {integrity: sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.92': + resolution: {integrity: sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.92': + resolution: {integrity: sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.92': + resolution: {integrity: sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.92': + resolution: {integrity: sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.92': + resolution: {integrity: sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -11526,6 +11604,53 @@ snapshots: - hono - supports-color + '@napi-rs/canvas-android-arm64@0.1.92': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.92': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.92': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.92': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.92': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.92': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.92': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.92': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.92': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.92': + optional: true + + '@napi-rs/canvas@0.1.92': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.92 + '@napi-rs/canvas-darwin-arm64': 0.1.92 + '@napi-rs/canvas-darwin-x64': 0.1.92 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.92 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.92 + '@napi-rs/canvas-linux-arm64-musl': 0.1.92 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.92 + '@napi-rs/canvas-linux-x64-gnu': 0.1.92 + '@napi-rs/canvas-linux-x64-musl': 0.1.92 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.92 + '@napi-rs/canvas-win32-x64-msvc': 0.1.92 + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts index 6092913fe..54cf8acd0 100644 --- a/server/api/registry/badge/[type]/[...pkg].get.ts +++ b/server/api/registry/badge/[type]/[...pkg].get.ts @@ -1,4 +1,5 @@ import * as v from 'valibot' +import { createCanvas, type SKRSContext2D } from '@napi-rs/canvas' import { hash } from 'ohash' import { createError, getRouterParam, getQuery, setHeader } from 'h3' import { PackageRouteParamsSchema } from '#shared/schemas/package' @@ -34,15 +35,47 @@ const COLORS = { white: '#ffffff', } -const DEFAULT_CHAR_WIDTH = 7 -const CHARS_WIDTH = { - engines: 5.5, +const CHAR_WIDTH = 7 + +const BADGE_PADDING_X = 8 +const MIN_BADGE_TEXT_WIDTH = 40 + +const BADGE_FONT_SHORTHAND = 'normal normal 400 11px Geist, system-ui, -apple-system, sans-serif' + +let cachedCanvasContext: SKRSContext2D | null | undefined + +function getCanvasContext(): SKRSContext2D | null { + if (cachedCanvasContext !== undefined) { + return cachedCanvasContext + } + + try { + cachedCanvasContext = createCanvas(1, 1).getContext('2d') + } catch { + cachedCanvasContext = null + } + + return cachedCanvasContext } -function measureTextWidth(text: string, charWidth?: number): number { - charWidth ??= DEFAULT_CHAR_WIDTH - const paddingX = 8 - return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2) +function fallbackMeasureTextWidth(text: string): number { + return Math.max(MIN_BADGE_TEXT_WIDTH, Math.round(text.length * CHAR_WIDTH) + BADGE_PADDING_X * 2) +} + +function measureTextWidth(text: string): number { + const context = getCanvasContext() + + if (context) { + context.font = BADGE_FONT_SHORTHAND + + const measuredWidth = context.measureText(text).width + + if (!Number.isNaN(measuredWidth)) { + return Math.max(MIN_BADGE_TEXT_WIDTH, Math.ceil(measuredWidth) + BADGE_PADDING_X * 2) + } + } + + return fallbackMeasureTextWidth(text) } function formatBytes(bytes: number): string { @@ -296,10 +329,7 @@ export default defineCachedEventHandler( const finalLabelColor = rawLabelColor?.startsWith('#') ? rawLabelColor : `#${rawLabelColor}` const leftWidth = finalLabel.trim().length === 0 ? 0 : measureTextWidth(finalLabel) - const rightWidth = measureTextWidth( - finalValue, - CHARS_WIDTH[strategyKey as keyof typeof CHARS_WIDTH], - ) + const rightWidth = measureTextWidth(finalValue) const totalWidth = leftWidth + rightWidth const height = 20 From 68c91690f57a77ab0e9e450d2f94b847d9757cac Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 14 Feb 2026 01:16:29 +0100 Subject: [PATCH 2/2] feat: add shields.io-style badges --- .../api/registry/badge/[type]/[...pkg].get.ts | 142 ++++++++++++++---- test/e2e/badge.spec.ts | 14 ++ 2 files changed, 125 insertions(+), 31 deletions(-) diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts index 54cf8acd0..de0403fcd 100644 --- a/server/api/registry/badge/[type]/[...pkg].get.ts +++ b/server/api/registry/badge/[type]/[...pkg].get.ts @@ -36,11 +36,14 @@ const COLORS = { } const CHAR_WIDTH = 7 +const SHIELDS_CHAR_WIDTH = 6 const BADGE_PADDING_X = 8 const MIN_BADGE_TEXT_WIDTH = 40 +const SHIELDS_LABEL_PADDING_X = 5 const BADGE_FONT_SHORTHAND = 'normal normal 400 11px Geist, system-ui, -apple-system, sans-serif' +const SHIELDS_FONT_SHORTHAND = 'normal normal 400 11px Verdana, Geneva, DejaVu Sans, sans-serif' let cachedCanvasContext: SKRSContext2D | null | undefined @@ -58,24 +61,115 @@ function getCanvasContext(): SKRSContext2D | null { return cachedCanvasContext } -function fallbackMeasureTextWidth(text: string): number { - return Math.max(MIN_BADGE_TEXT_WIDTH, Math.round(text.length * CHAR_WIDTH) + BADGE_PADDING_X * 2) -} - -function measureTextWidth(text: string): number { +function measureTextWidth(text: string, font: string): number | null { const context = getCanvasContext() if (context) { - context.font = BADGE_FONT_SHORTHAND + context.font = font const measuredWidth = context.measureText(text).width if (!Number.isNaN(measuredWidth)) { - return Math.max(MIN_BADGE_TEXT_WIDTH, Math.ceil(measuredWidth) + BADGE_PADDING_X * 2) + return Math.ceil(measuredWidth) } } - return fallbackMeasureTextWidth(text) + return null +} + +function measureDefaultTextWidth(text: string): number { + const measuredWidth = measureTextWidth(text, BADGE_FONT_SHORTHAND) + + if (measuredWidth !== null) { + return Math.max(MIN_BADGE_TEXT_WIDTH, measuredWidth + BADGE_PADDING_X * 2) + } + + return Math.max(MIN_BADGE_TEXT_WIDTH, Math.round(text.length * CHAR_WIDTH) + BADGE_PADDING_X * 2) +} + +function measureShieldsTextLength(text: string): number { + const measuredWidth = measureTextWidth(text, SHIELDS_FONT_SHORTHAND) + + if (measuredWidth !== null) { + return Math.max(1, measuredWidth) + } + + return Math.max(1, Math.round(text.length * SHIELDS_CHAR_WIDTH)) +} + +function renderDefaultBadgeSvg(params: { + finalColor: string + finalLabel: string + finalLabelColor: string + finalValue: string +}): string { + const { finalColor, finalLabel, finalLabelColor, finalValue } = params + const leftWidth = finalLabel.trim().length === 0 ? 0 : measureDefaultTextWidth(finalLabel) + const rightWidth = measureDefaultTextWidth(finalValue) + const totalWidth = leftWidth + rightWidth + const height = 20 + + return ` + + + + + + + + + + ${finalLabel} + ${finalValue} + + + `.trim() +} + +function renderShieldsBadgeSvg(params: { + finalColor: string + finalLabel: string + finalLabelColor: string + finalValue: string +}): string { + const { finalColor, finalLabel, finalLabelColor, finalValue } = params + const hasLabel = finalLabel.trim().length > 0 + + const leftTextLength = hasLabel ? measureShieldsTextLength(finalLabel) : 0 + const rightTextLength = measureShieldsTextLength(finalValue) + const leftWidth = hasLabel ? leftTextLength + SHIELDS_LABEL_PADDING_X * 2 : 0 + const rightWidth = rightTextLength + SHIELDS_LABEL_PADDING_X * 2 + const totalWidth = leftWidth + rightWidth + const height = 20 + const title = `${finalLabel}: ${finalValue}` + + const leftCenter = Math.round((leftWidth / 2) * 10) + const rightCenter = Math.round((leftWidth + rightWidth / 2) * 10) + const leftTextLengthAttr = leftTextLength * 10 + const rightTextLengthAttr = rightTextLength * 10 + + return ` + + + + + + + + + + + + + + + + ${finalLabel} + + ${finalValue} + + + `.trim() } function formatBytes(bytes: number): string { @@ -284,6 +378,7 @@ const badgeStrategies = { } const BadgeTypeSchema = v.picklist(Object.keys(badgeStrategies) as [string, ...string[]]) +const BadgeStyleSchema = v.picklist(['default', 'shieldsio']) export default defineCachedEventHandler( async event => { @@ -309,6 +404,8 @@ export default defineCachedEventHandler( const labelColor = queryParams.success ? queryParams.output.labelColor : undefined const showName = queryParams.success && queryParams.output.name === 'true' const userLabel = queryParams.success ? queryParams.output.label : undefined + const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style) + const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default' const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam) const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version' @@ -325,29 +422,12 @@ export default defineCachedEventHandler( const rawColor = userColor ?? strategyResult.color const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}` - const rawLabelColor = labelColor ?? '#0a0a0a' - const finalLabelColor = rawLabelColor?.startsWith('#') ? rawLabelColor : `#${rawLabelColor}` - - const leftWidth = finalLabel.trim().length === 0 ? 0 : measureTextWidth(finalLabel) - const rightWidth = measureTextWidth(finalValue) - const totalWidth = leftWidth + rightWidth - const height = 20 - - const svg = ` - - - - - - - - - - ${finalLabel} - ${finalValue} - - - `.trim() + const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a' + const rawLabelColor = labelColor ?? defaultLabelColor + const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}` + + const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg + const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue }) setHeader(event, 'Content-Type', 'image/svg+xml') setHeader( diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index ff6f972d4..3fe9906a4 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -126,6 +126,20 @@ test.describe('badge API', () => { expect(body).toContain(customLabel) }) + test('style=default keeps current badge renderer', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=default') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('font-family="Geist, system-ui, -apple-system, sans-serif"') + }) + + test('style=shieldsio renders shields.io-like badge', async ({ page, baseURL }) => { + const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=shieldsio') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('font-family="Verdana, Geneva, DejaVu Sans, sans-serif"') + }) + test('invalid badge type defaults to version strategy', async ({ page, baseURL }) => { const url = toLocalUrl(baseURL, '/api/registry/badge/invalid-type/nuxt') const { body } = await fetchBadge(page, url)