From a52779d1cde17a7ffc817b7825cf1d5b31c5594e Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 14 Feb 2026 00:53:07 +0100 Subject: [PATCH] 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