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