diff --git a/src/components/npm-stats/NPMStatsChart.tsx b/src/components/npm-stats/NPMStatsChart.tsx index 97f272c10..5ee21b064 100644 --- a/src/components/npm-stats/NPMStatsChart.tsx +++ b/src/components/npm-stats/NPMStatsChart.tsx @@ -17,6 +17,7 @@ import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/charts/react' import { GIFEncoder, applyPalette, quantize } from 'gifenc' +import { getSvgDimensions } from './svgDimensions' import { CheckIcon, CodeIcon, @@ -851,18 +852,6 @@ function createLatestBarExportChart({ return svg ?? undefined } -function getSvgDimensions(svg: SVGSVGElement) { - const box = svg.getBoundingClientRect() - const viewBox = svg.viewBox.baseVal - const width = svg.width.baseVal.value || viewBox.width || box.width || 1 - const height = svg.height.baseVal.value || viewBox.height || box.height || 1 - - return { - width: Math.max(1, Math.ceil(width)), - height: Math.max(1, Math.ceil(height)), - } -} - function isTransparentColor(color: string) { const normalizedColor = color.trim().toLowerCase() diff --git a/src/components/npm-stats/svgDimensions.ts b/src/components/npm-stats/svgDimensions.ts new file mode 100644 index 000000000..236ffa99f --- /dev/null +++ b/src/components/npm-stats/svgDimensions.ts @@ -0,0 +1,55 @@ +// SVGLength.SVG_LENGTHTYPE_PERCENTAGE. Inlined so this module does not depend on +// the SVGLength global (unavailable outside the browser). +const SVG_LENGTHTYPE_PERCENTAGE = 2 + +type AnimatedLengthLike = { + baseVal: { unitType: number; value: number } +} + +type SvgLike = { + width: AnimatedLengthLike + height: AnimatedLengthLike + viewBox: { baseVal: { width: number; height: number } | null } + getBoundingClientRect: () => { width: number; height: number } +} + +function isNotSupportedError(error: unknown) { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'NotSupportedError' + ) +} + +/** + * Reads an absolute SVG length in user units, returning 0 when the length is + * relative (e.g. `width="100%"`). Browsers throw `NotSupportedError` when a + * relative length cannot be resolved, which happens for SVGs that are not + * attached to the document (such as off-screen animated export frames). + */ +export function readAbsoluteSvgLength(length: AnimatedLengthLike) { + const baseVal = length.baseVal + if (baseVal.unitType === SVG_LENGTHTYPE_PERCENTAGE) return 0 + + try { + return baseVal.value + } catch (error) { + if (isNotSupportedError(error)) return 0 + throw error + } +} + +export function getSvgDimensions(svg: SvgLike) { + const box = svg.getBoundingClientRect() + const viewBox = svg.viewBox.baseVal + const width = + readAbsoluteSvgLength(svg.width) || viewBox?.width || box.width || 1 + const height = + readAbsoluteSvgLength(svg.height) || viewBox?.height || box.height || 1 + + return { + width: Math.max(1, Math.ceil(width)), + height: Math.max(1, Math.ceil(height)), + } +} diff --git a/tests/npm-svg-dimensions.test.ts b/tests/npm-svg-dimensions.test.ts new file mode 100644 index 000000000..a00ede6aa --- /dev/null +++ b/tests/npm-svg-dimensions.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + getSvgDimensions, + readAbsoluteSvgLength, +} from '../src/components/npm-stats/svgDimensions' + +const SVG_LENGTHTYPE_NUMBER = 1 +const SVG_LENGTHTYPE_PERCENTAGE = 2 + +function length(unitType: number, value: number) { + return { baseVal: { unitType, value } } +} + +function throwingLength(error: Error) { + return { + baseVal: { + unitType: SVG_LENGTHTYPE_NUMBER, + get value(): number { + throw error + }, + }, + } +} + +function notSupportedError() { + const error = new Error('Could not resolve relative length.') + error.name = 'NotSupportedError' + return error +} + +function svg({ + width, + height, + viewBox = { width: 640, height: 320 }, + box = { width: 0, height: 0 }, +}: { + width: ReturnType | ReturnType + height: ReturnType | ReturnType + viewBox?: { width: number; height: number } | null + box?: { width: number; height: number } +}) { + return { + width, + height, + viewBox: { baseVal: viewBox }, + getBoundingClientRect: () => box, + } +} + +test('percentage lengths fall back to the viewBox size', () => { + const percentageLength = { + baseVal: { + unitType: SVG_LENGTHTYPE_PERCENTAGE, + get value(): number { + throw notSupportedError() + }, + }, + } + + assert.deepEqual( + getSvgDimensions( + svg({ width: percentageLength, height: percentageLength }), + ), + { width: 640, height: 320 }, + ) +}) + +test('unresolvable lengths fall back instead of throwing', () => { + assert.deepEqual( + getSvgDimensions( + svg({ + width: throwingLength(notSupportedError()), + height: throwingLength(notSupportedError()), + viewBox: null, + box: { width: 400.2, height: 200.5 }, + }), + ), + { width: 401, height: 201 }, + ) +}) + +test('absolute lengths are used directly', () => { + assert.deepEqual( + getSvgDimensions( + svg({ + width: length(SVG_LENGTHTYPE_NUMBER, 800), + height: length(SVG_LENGTHTYPE_NUMBER, 400), + }), + ), + { width: 800, height: 400 }, + ) +}) + +test('unexpected errors are not swallowed', () => { + assert.throws( + () => readAbsoluteSvgLength(throwingLength(new TypeError('boom'))), + TypeError, + ) +})