Compare commits

...

3 Commits

Author SHA1 Message Date
Brett Jephson 19179f537d Merge branch 'main' into brett/font-clipping 2026-04-29 17:42:05 +01:00
Brett Jephson e56bb116b1 fix icon viewboxes 2026-04-29 12:14:35 +01:00
Brett Jephson 0933175a89 use sprite-full for icons 2026-04-29 09:40:59 +01:00
10 changed files with 1310 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/icons": patch
---
Icon clipping fix
+2
View File
@@ -1,2 +1,4 @@
dist/
src/data/*.json
!src/data/metrics.json
public/
+3
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import url from 'node:url';
import { allStyles, collectNormalizedIconAssets, createMetricsManifest } from './icon-assets.js';
import { getKitPath } from './kit.js';
/**
@@ -12,6 +13,7 @@ async function main() {
const icons = JSON.parse(
await fs.readFile(path.join(source, 'metadata/icon-families.json'), 'utf8')
);
const normalizedIconAssets = await collectNormalizedIconAssets(source, allStyles);
// Only these families have exceptions
const potentialOnly = ['brands', 'custom-icons'];
@@ -53,6 +55,7 @@ async function main() {
await Promise.all([
writeDataFile('styles-map', JSON.stringify(onlyStyles, null, 2)),
writeDataFile('icons', JSON.stringify(result, null, 2)),
writeDataFile('metrics', JSON.stringify(createMetricsManifest(normalizedIconAssets))),
]);
// biome-ignore lint/suspicious/noConsole: We want the CLI to log
+28 -25
View File
@@ -1,12 +1,10 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { allStyles, collectNormalizedIconAssets, createMetricsManifest } from './icon-assets.js';
import { getKitPath } from './kit.js';
const allStyles = ['brands', 'duotone', 'solid', 'regular', 'light', 'thin', 'custom-icons'];
/**
* Scripts to copy the assets to a public folder.
*/
@@ -16,36 +14,41 @@ async function main() {
(style) => allStyles.includes(style)
);
const source = getKitPath();
const iconAssets = await collectNormalizedIconAssets(source, stylesToCopy);
// Create the output folder if it doesn't exist
await fs.mkdir(outputFolder, { recursive: true });
// Copy the assets from
// source/sprites to outputFolder/sprites
// source/svgs to outputFolder/svgs
await Promise.all([
fs.mkdir(outputFolder, { recursive: true }),
...stylesToCopy.map((style) =>
fs.mkdir(path.join(outputFolder, 'svgs', style), { recursive: true })
),
fs.mkdir(path.join(outputFolder, 'sprites'), { recursive: true }),
]);
// Write normalized SVG assets and copy style sprites.
await Promise.all([
...iconAssets.map((asset) =>
fs.writeFile(
path.join(outputFolder, 'svgs', asset.style, `${asset.icon}.svg`),
asset.svg
)
),
...stylesToCopy.map((style) => {
const stylePath = path.join(source, 'svgs', style);
if (!existsSync(stylePath)) {
} else {
return fs.cp(stylePath, path.join(outputFolder, 'svgs', style), {
recursive: true,
});
}
}),
...stylesToCopy.map((style) => {
const spritePath = path.join(source, `sprites/${style}.svg`);
if (existsSync(spritePath)) {
return fs.cp(
path.join(source, `sprites/${style}.svg`),
path.join(outputFolder, 'sprites', `${style}.svg`)
);
}
const spritePath = path.join(source, 'sprites', `${style}.svg`);
return fs
.access(spritePath)
.then(() => fs.cp(spritePath, path.join(outputFolder, 'sprites', `${style}.svg`)));
}),
fs.writeFile(
path.join(outputFolder, 'metrics.json'),
JSON.stringify(createMetricsManifest(iconAssets))
),
]);
// biome-ignore lint/suspicious/noConsole: We want the CLI to log
console.log(`Copied ${stylesToCopy.length} styles to ${outputFolder}`);
console.log(
`Copied ${iconAssets.length} icons across ${stylesToCopy.length} styles to ${outputFolder}`
);
}
main().catch((error) => {
+653
View File
@@ -0,0 +1,653 @@
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { absolutize, parsePath } from './path-data.js';
export const allStyles = ['brands', 'duotone', 'solid', 'regular', 'light', 'thin', 'custom-icons'];
const metadataStyleByOutputStyle = {
'custom-icons': 'custom',
};
const VIEWBOX_PRECISION = 10_000;
const FLOAT_EPSILON = 1e-9;
/**
* Normalize the icon assets from the Font Awesome kit to safe SVGs with metrics.
*/
export async function collectNormalizedIconAssets(source, styles = allStyles) {
const iconsMetadata = await loadIconsMetadata(source);
const records = [];
for (const style of styles) {
const stylePath = path.join(source, 'svgs', style);
if (!existsSync(stylePath)) {
continue;
}
const files = (await fs.readdir(stylePath))
.filter((file) => file.endsWith('.svg'))
.sort((left, right) => left.localeCompare(right));
for (const file of files) {
const icon = file.slice(0, -4);
const svgPath = path.join(stylePath, file);
const svg = await fs.readFile(svgPath, 'utf8');
const originalViewBox = parseSvgViewBox(svg);
const pathData = getIconPaths(iconsMetadata, icon, style);
const safeViewBox = pathData
? getSafeViewBox(pathData, originalViewBox)
: originalViewBox;
records.push({
style,
icon,
originalViewBox,
safeViewBox,
svg: replaceSvgViewBox(svg, safeViewBox),
});
}
}
return records;
}
/**
* Create a manifest keyed by "style/icon" to keep runtime lookups simple.
*/
export function createMetricsManifest(records) {
return Object.fromEntries(
records
.filter((record) => !viewBoxesEqual(record.originalViewBox, record.safeViewBox))
.map((record) => [
`${record.style}/${record.icon}`,
{
originalViewBox: record.originalViewBox,
safeViewBox: record.safeViewBox,
},
])
);
}
/**
* Calculate a safe viewBox that contains both the declared Font Awesome box and the actual paint.
*/
export function getSafeViewBox(paths, originalViewBox) {
const paintBounds = getPaintBounds(paths);
if (!paintBounds) {
return originalViewBox;
}
const [originalX, originalY, originalWidth, originalHeight] = originalViewBox;
const originalMaxX = originalX + originalWidth;
const originalMaxY = originalY + originalHeight;
const minX = preserveMinEdge(originalX, paintBounds.minX);
const minY = preserveMinEdge(originalY, paintBounds.minY);
const maxX = preserveMaxEdge(originalMaxX, paintBounds.maxX);
const maxY = preserveMaxEdge(originalMaxY, paintBounds.maxY);
return [
normalizeNumber(minX),
normalizeNumber(minY),
normalizeNumber(maxX - minX),
normalizeNumber(maxY - minY),
];
}
/**
* Compute the exact painted bounds for filled SVG paths.
*/
export function getPaintBounds(paths) {
const allPaths = Array.isArray(paths) ? paths : [paths];
let bounds = null;
for (const pathData of allPaths) {
const segments = absolutize(parsePath(pathData));
let currentPoint = null;
let subpathStart = null;
let lastType = '';
let lastCubicControl = null;
let lastQuadraticControl = null;
for (const segment of segments) {
switch (segment.key) {
case 'M':
currentPoint = [segment.data[0], segment.data[1]];
subpathStart = currentPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
case 'L': {
if (!currentPoint) {
currentPoint = [segment.data[0], segment.data[1]];
break;
}
const nextPoint = [segment.data[0], segment.data[1]];
bounds = includeLineBounds(bounds, currentPoint, nextPoint);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case 'H': {
if (!currentPoint) {
currentPoint = [segment.data[0], 0];
break;
}
const nextPoint = [segment.data[0], currentPoint[1]];
bounds = includeLineBounds(bounds, currentPoint, nextPoint);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case 'V': {
if (!currentPoint) {
currentPoint = [0, segment.data[0]];
break;
}
const nextPoint = [currentPoint[0], segment.data[0]];
bounds = includeLineBounds(bounds, currentPoint, nextPoint);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case 'C': {
if (!currentPoint) {
currentPoint = [segment.data[4], segment.data[5]];
break;
}
const curveBounds = getCubicBounds(
currentPoint,
[segment.data[0], segment.data[1]],
[segment.data[2], segment.data[3]],
[segment.data[4], segment.data[5]]
);
bounds = mergeBounds(bounds, curveBounds);
currentPoint = [segment.data[4], segment.data[5]];
lastCubicControl = [segment.data[2], segment.data[3]];
lastQuadraticControl = null;
break;
}
case 'S': {
if (!currentPoint) {
currentPoint = [segment.data[2], segment.data[3]];
break;
}
const control1 =
lastType === 'C' || lastType === 'S'
? reflectPoint(currentPoint, lastCubicControl)
: currentPoint;
const control2 = [segment.data[0], segment.data[1]];
const nextPoint = [segment.data[2], segment.data[3]];
bounds = mergeBounds(
bounds,
getCubicBounds(currentPoint, control1, control2, nextPoint)
);
currentPoint = nextPoint;
lastCubicControl = control2;
lastQuadraticControl = null;
break;
}
case 'Q': {
if (!currentPoint) {
currentPoint = [segment.data[2], segment.data[3]];
break;
}
const control = [segment.data[0], segment.data[1]];
const nextPoint = [segment.data[2], segment.data[3]];
bounds = mergeBounds(
bounds,
getQuadraticBounds(currentPoint, control, nextPoint)
);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = control;
break;
}
case 'T': {
if (!currentPoint) {
currentPoint = [segment.data[0], segment.data[1]];
break;
}
const control =
lastType === 'Q' || lastType === 'T'
? reflectPoint(currentPoint, lastQuadraticControl)
: currentPoint;
const nextPoint = [segment.data[0], segment.data[1]];
bounds = mergeBounds(
bounds,
getQuadraticBounds(currentPoint, control, nextPoint)
);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = control;
break;
}
case 'A': {
if (!currentPoint) {
currentPoint = [segment.data[5], segment.data[6]];
break;
}
const nextPoint = [segment.data[5], segment.data[6]];
bounds = mergeBounds(
bounds,
getArcBounds(
currentPoint,
nextPoint,
segment.data[0],
segment.data[1],
segment.data[2],
segment.data[3],
segment.data[4]
)
);
currentPoint = nextPoint;
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
case 'Z':
if (currentPoint && subpathStart) {
bounds = includeLineBounds(bounds, currentPoint, subpathStart);
currentPoint = subpathStart;
}
lastCubicControl = null;
lastQuadraticControl = null;
break;
}
lastType = segment.key;
}
}
return bounds;
}
function getIconPaths(iconsMetadata, icon, style) {
const metadataStyle = metadataStyleByOutputStyle[style] ?? style;
const styleMetadata = iconsMetadata[icon]?.svg?.[metadataStyle];
if (!styleMetadata) {
return null;
}
return Array.isArray(styleMetadata.path) ? styleMetadata.path : [styleMetadata.path];
}
async function loadIconsMetadata(source) {
const metadataFile = path.join(source, 'metadata/icons.json');
return JSON.parse(await fs.readFile(metadataFile, 'utf8'));
}
function parseSvgViewBox(svg) {
const match = svg.match(/\bviewBox="([^"]+)"/);
if (!match) {
throw new Error('SVG is missing a viewBox');
}
const numbers = match[1]
.trim()
.split(/\s+/)
.map((value) => Number.parseFloat(value));
if (numbers.length !== 4 || numbers.some((value) => Number.isNaN(value))) {
throw new Error(`Invalid SVG viewBox: ${match[1]}`);
}
return numbers;
}
function replaceSvgViewBox(svg, viewBox) {
return svg.replace(/\bviewBox="[^"]+"/, `viewBox="${formatViewBox(viewBox)}"`);
}
function formatViewBox(viewBox) {
return viewBox.map((value) => formatNumber(value)).join(' ');
}
function formatNumber(value) {
const normalized = normalizeNumber(value);
return Number.isInteger(normalized) ? `${normalized}` : `${normalized}`;
}
function preserveMinEdge(originalMin, paintMin) {
if (paintMin >= originalMin - FLOAT_EPSILON) {
return originalMin;
}
return roundDown(paintMin);
}
function preserveMaxEdge(originalMax, paintMax) {
if (paintMax <= originalMax + FLOAT_EPSILON) {
return originalMax;
}
return roundUp(paintMax);
}
function roundDown(value) {
return Math.floor(value * VIEWBOX_PRECISION) / VIEWBOX_PRECISION;
}
function roundUp(value) {
return Math.ceil(value * VIEWBOX_PRECISION) / VIEWBOX_PRECISION;
}
function normalizeNumber(value) {
return Math.round(value * VIEWBOX_PRECISION) / VIEWBOX_PRECISION;
}
function viewBoxesEqual(left, right) {
return left.every((value, index) => Math.abs(value - right[index]) < FLOAT_EPSILON);
}
function includeLineBounds(bounds, start, end) {
return mergeBounds(bounds, {
minX: Math.min(start[0], end[0]),
minY: Math.min(start[1], end[1]),
maxX: Math.max(start[0], end[0]),
maxY: Math.max(start[1], end[1]),
});
}
function mergeBounds(bounds, nextBounds) {
if (!bounds) {
return nextBounds;
}
return {
minX: Math.min(bounds.minX, nextBounds.minX),
minY: Math.min(bounds.minY, nextBounds.minY),
maxX: Math.max(bounds.maxX, nextBounds.maxX),
maxY: Math.max(bounds.maxY, nextBounds.maxY),
};
}
function reflectPoint(origin, point) {
if (!point) {
return origin;
}
return [2 * origin[0] - point[0], 2 * origin[1] - point[1]];
}
function getQuadraticBounds(start, control, end) {
const cubicControl1 = [
start[0] + (2 * (control[0] - start[0])) / 3,
start[1] + (2 * (control[1] - start[1])) / 3,
];
const cubicControl2 = [
end[0] + (2 * (control[0] - end[0])) / 3,
end[1] + (2 * (control[1] - end[1])) / 3,
];
return getCubicBounds(start, cubicControl1, cubicControl2, end);
}
function getCubicBounds(start, control1, control2, end) {
const candidates = [
0,
1,
...getCubicExtrema(start[0], control1[0], control2[0], end[0]),
...getCubicExtrema(start[1], control1[1], control2[1], end[1]),
];
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const t of candidates) {
if (t < 0 || t > 1) {
continue;
}
const point = evaluateCubic(start, control1, control2, end, t);
minX = Math.min(minX, point[0]);
minY = Math.min(minY, point[1]);
maxX = Math.max(maxX, point[0]);
maxY = Math.max(maxY, point[1]);
}
return { minX, minY, maxX, maxY };
}
function getArcBounds(start, end, rawRadiusX, rawRadiusY, angle, largeArcFlag, sweepFlag) {
const arc = endpointToCenterArc(
start[0],
start[1],
end[0],
end[1],
rawRadiusX,
rawRadiusY,
angle,
largeArcFlag,
sweepFlag
);
if (!arc) {
return {
minX: Math.min(start[0], end[0]),
minY: Math.min(start[1], end[1]),
maxX: Math.max(start[0], end[0]),
maxY: Math.max(start[1], end[1]),
};
}
const extrema = getArcExtremaAngles(arc.radiusX, arc.radiusY, arc.rotation);
const candidates = [arc.startAngle, arc.startAngle + arc.deltaAngle, ...extrema];
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const candidate of candidates) {
if (!isAngleOnArc(candidate, arc.startAngle, arc.deltaAngle)) {
continue;
}
const point = pointOnArc(arc, candidate);
minX = Math.min(minX, point[0]);
minY = Math.min(minY, point[1]);
maxX = Math.max(maxX, point[0]);
maxY = Math.max(maxY, point[1]);
}
return { minX, minY, maxX, maxY };
}
function endpointToCenterArc(
startX,
startY,
endX,
endY,
rawRadiusX,
rawRadiusY,
angle,
largeArcFlag,
sweepFlag
) {
let radiusX = Math.abs(rawRadiusX);
let radiusY = Math.abs(rawRadiusY);
if (
radiusX < FLOAT_EPSILON ||
radiusY < FLOAT_EPSILON ||
(Math.abs(startX - endX) < FLOAT_EPSILON && Math.abs(startY - endY) < FLOAT_EPSILON)
) {
return null;
}
const rotation = degToRad(angle % 360);
const cosine = Math.cos(rotation);
const sine = Math.sin(rotation);
const translatedX = (startX - endX) / 2;
const translatedY = (startY - endY) / 2;
const primeX = cosine * translatedX + sine * translatedY;
const primeY = -sine * translatedX + cosine * translatedY;
const lambda =
(primeX * primeX) / (radiusX * radiusX) + (primeY * primeY) / (radiusY * radiusY);
if (lambda > 1) {
const scale = Math.sqrt(lambda);
radiusX *= scale;
radiusY *= scale;
}
const radiusXSquared = radiusX * radiusX;
const radiusYSquared = radiusY * radiusY;
const primeXSquared = primeX * primeX;
const primeYSquared = primeY * primeY;
const numerator =
radiusXSquared * radiusYSquared -
radiusXSquared * primeYSquared -
radiusYSquared * primeXSquared;
const denominator = radiusXSquared * primeYSquared + radiusYSquared * primeXSquared;
const factor =
(largeArcFlag === sweepFlag ? -1 : 1) * Math.sqrt(Math.max(0, numerator / denominator));
const centerPrimeX = (factor * radiusX * primeY) / radiusY;
const centerPrimeY = (-factor * radiusY * primeX) / radiusX;
const centerX = cosine * centerPrimeX - sine * centerPrimeY + (startX + endX) / 2;
const centerY = sine * centerPrimeX + cosine * centerPrimeY + (startY + endY) / 2;
const startVector = [(primeX - centerPrimeX) / radiusX, (primeY - centerPrimeY) / radiusY];
const endVector = [(-primeX - centerPrimeX) / radiusX, (-primeY - centerPrimeY) / radiusY];
const startAngle = vectorAngle([1, 0], startVector);
let deltaAngle = vectorAngle(startVector, endVector);
if (!sweepFlag && deltaAngle > 0) {
deltaAngle -= 2 * Math.PI;
}
if (sweepFlag && deltaAngle < 0) {
deltaAngle += 2 * Math.PI;
}
return {
centerX,
centerY,
radiusX,
radiusY,
rotation,
startAngle,
deltaAngle,
};
}
function getArcExtremaAngles(radiusX, radiusY, rotation) {
const xAngle = Math.atan2(-radiusY * Math.sin(rotation), radiusX * Math.cos(rotation));
const yAngle = Math.atan2(radiusY * Math.cos(rotation), radiusX * Math.sin(rotation));
return [xAngle, xAngle + Math.PI, yAngle, yAngle + Math.PI];
}
function isAngleOnArc(angle, startAngle, deltaAngle) {
const fullTurn = 2 * Math.PI;
const endAngle = startAngle + deltaAngle;
if (deltaAngle >= 0) {
let normalized = angle;
while (normalized < startAngle - FLOAT_EPSILON) {
normalized += fullTurn;
}
while (normalized > startAngle + fullTurn + FLOAT_EPSILON) {
normalized -= fullTurn;
}
return normalized <= endAngle + FLOAT_EPSILON;
}
let normalized = angle;
while (normalized > startAngle + FLOAT_EPSILON) {
normalized -= fullTurn;
}
while (normalized < startAngle - fullTurn - FLOAT_EPSILON) {
normalized += fullTurn;
}
return normalized >= endAngle - FLOAT_EPSILON;
}
function pointOnArc(arc, angle) {
const cosine = Math.cos(arc.rotation);
const sine = Math.sin(arc.rotation);
const localX = arc.radiusX * Math.cos(angle);
const localY = arc.radiusY * Math.sin(angle);
return [
arc.centerX + localX * cosine - localY * sine,
arc.centerY + localX * sine + localY * cosine,
];
}
function vectorAngle(left, right) {
const dot = left[0] * right[0] + left[1] * right[1];
const magnitude = Math.hypot(left[0], left[1]) * Math.hypot(right[0], right[1]);
const sign = left[0] * right[1] - left[1] * right[0] < 0 ? -1 : 1;
return sign * Math.acos(clamp(dot / magnitude, -1, 1));
}
function degToRad(degrees) {
return (Math.PI * degrees) / 180;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getCubicExtrema(start, control1, control2, end) {
const a = -start + 3 * control1 - 3 * control2 + end;
const b = 2 * (start - 2 * control1 + control2);
const c = -start + control1;
if (Math.abs(a) < FLOAT_EPSILON) {
if (Math.abs(b) < FLOAT_EPSILON) {
return [];
}
return [-c / b].filter((value) => value > FLOAT_EPSILON && value < 1 - FLOAT_EPSILON);
}
const discriminant = b * b - 4 * a * c;
if (discriminant < -FLOAT_EPSILON) {
return [];
}
if (Math.abs(discriminant) < FLOAT_EPSILON) {
return [-b / (2 * a)].filter((value) => value > FLOAT_EPSILON && value < 1 - FLOAT_EPSILON);
}
const root = Math.sqrt(discriminant);
return [(-b + root) / (2 * a), (-b - root) / (2 * a)].filter(
(value) => value > FLOAT_EPSILON && value < 1 - FLOAT_EPSILON
);
}
function evaluateCubic(start, control1, control2, end, t) {
const oneMinusT = 1 - t;
return [
oneMinusT ** 3 * start[0] +
3 * oneMinusT ** 2 * t * control1[0] +
3 * oneMinusT * t ** 2 * control2[0] +
t ** 3 * end[0],
oneMinusT ** 3 * start[1] +
3 * oneMinusT ** 2 * t * control1[1] +
3 * oneMinusT * t ** 2 * control2[1] +
t ** 3 * end[1],
];
}
+531
View File
@@ -0,0 +1,531 @@
// Adapted from path-data-parser (MIT) to keep the icon CLI self-contained.
const COMMAND = 0;
const NUMBER = 1;
const EOD = 2;
const PARAMS = {
A: 7,
a: 7,
C: 6,
c: 6,
H: 1,
h: 1,
L: 2,
l: 2,
M: 2,
m: 2,
Q: 4,
q: 4,
S: 4,
s: 4,
T: 2,
t: 2,
V: 1,
v: 1,
Z: 0,
z: 0,
};
/**
* Parse an SVG path string into segment commands.
*/
export function parsePath(d) {
const segments = [];
const tokens = tokenize(d);
let mode = 'BOD';
let index = 0;
let token = tokens[index];
while (!isType(token, EOD)) {
let paramsCount = 0;
const params = [];
if (mode === 'BOD') {
if (token.text === 'M' || token.text === 'm') {
index++;
paramsCount = PARAMS[token.text];
mode = token.text;
} else {
return parsePath(`M0,0${d}`);
}
} else if (isType(token, NUMBER)) {
paramsCount = PARAMS[mode];
} else {
index++;
paramsCount = PARAMS[token.text];
mode = token.text;
}
if (index + paramsCount >= tokens.length) {
throw new Error('Path data ended short');
}
for (let i = index; i < index + paramsCount; i++) {
const numberToken = tokens[i];
if (!isType(numberToken, NUMBER)) {
throw new Error(`Param not a number: ${mode},${numberToken.text}`);
}
params.push(Number(numberToken.text));
}
if (typeof PARAMS[mode] !== 'number') {
throw new Error(`Bad segment: ${mode}`);
}
segments.push({ key: mode, data: params });
index += paramsCount;
token = tokens[index];
if (mode === 'M') {
mode = 'L';
}
if (mode === 'm') {
mode = 'l';
}
}
return segments;
}
/**
* Translate relative SVG commands to absolute commands.
*/
export function absolutize(segments) {
let currentX = 0;
let currentY = 0;
let subpathX = 0;
let subpathY = 0;
const output = [];
for (const { key, data } of segments) {
switch (key) {
case 'M':
output.push({ key: 'M', data: [...data] });
[currentX, currentY] = data;
[subpathX, subpathY] = data;
break;
case 'm':
currentX += data[0];
currentY += data[1];
output.push({ key: 'M', data: [currentX, currentY] });
subpathX = currentX;
subpathY = currentY;
break;
case 'L':
output.push({ key: 'L', data: [...data] });
[currentX, currentY] = data;
break;
case 'l':
currentX += data[0];
currentY += data[1];
output.push({ key: 'L', data: [currentX, currentY] });
break;
case 'C':
output.push({ key: 'C', data: [...data] });
currentX = data[4];
currentY = data[5];
break;
case 'c': {
const nextData = data.map((value, index) =>
index % 2 === 0 ? value + currentX : value + currentY
);
output.push({ key: 'C', data: nextData });
currentX = nextData[4];
currentY = nextData[5];
break;
}
case 'Q':
output.push({ key: 'Q', data: [...data] });
currentX = data[2];
currentY = data[3];
break;
case 'q': {
const nextData = data.map((value, index) =>
index % 2 === 0 ? value + currentX : value + currentY
);
output.push({ key: 'Q', data: nextData });
currentX = nextData[2];
currentY = nextData[3];
break;
}
case 'A':
output.push({ key: 'A', data: [...data] });
currentX = data[5];
currentY = data[6];
break;
case 'a':
currentX += data[5];
currentY += data[6];
output.push({
key: 'A',
data: [data[0], data[1], data[2], data[3], data[4], currentX, currentY],
});
break;
case 'H':
output.push({ key: 'H', data: [...data] });
currentX = data[0];
break;
case 'h':
currentX += data[0];
output.push({ key: 'H', data: [currentX] });
break;
case 'V':
output.push({ key: 'V', data: [...data] });
currentY = data[0];
break;
case 'v':
currentY += data[0];
output.push({ key: 'V', data: [currentY] });
break;
case 'S':
output.push({ key: 'S', data: [...data] });
currentX = data[2];
currentY = data[3];
break;
case 's': {
const nextData = data.map((value, index) =>
index % 2 === 0 ? value + currentX : value + currentY
);
output.push({ key: 'S', data: nextData });
currentX = nextData[2];
currentY = nextData[3];
break;
}
case 'T':
output.push({ key: 'T', data: [...data] });
currentX = data[0];
currentY = data[1];
break;
case 't':
currentX += data[0];
currentY += data[1];
output.push({ key: 'T', data: [currentX, currentY] });
break;
case 'Z':
case 'z':
output.push({ key: 'Z', data: [] });
currentX = subpathX;
currentY = subpathY;
break;
}
}
return output;
}
/**
* Normalize an absolute path to M/L/C/Z commands only.
*/
export function normalize(segments) {
const output = [];
let lastType = '';
let currentX = 0;
let currentY = 0;
let subpathX = 0;
let subpathY = 0;
let lastControlX = 0;
let lastControlY = 0;
for (const { key, data } of segments) {
switch (key) {
case 'M':
output.push({ key: 'M', data: [...data] });
[currentX, currentY] = data;
[subpathX, subpathY] = data;
break;
case 'C':
output.push({ key: 'C', data: [...data] });
currentX = data[4];
currentY = data[5];
lastControlX = data[2];
lastControlY = data[3];
break;
case 'L':
output.push({ key: 'L', data: [...data] });
[currentX, currentY] = data;
break;
case 'H':
currentX = data[0];
output.push({ key: 'L', data: [currentX, currentY] });
break;
case 'V':
currentY = data[0];
output.push({ key: 'L', data: [currentX, currentY] });
break;
case 'S': {
let controlX = currentX;
let controlY = currentY;
if (lastType === 'C' || lastType === 'S') {
controlX = currentX + (currentX - lastControlX);
controlY = currentY + (currentY - lastControlY);
}
output.push({ key: 'C', data: [controlX, controlY, ...data] });
lastControlX = data[0];
lastControlY = data[1];
currentX = data[2];
currentY = data[3];
break;
}
case 'T': {
const [x, y] = data;
let reflectedX = currentX;
let reflectedY = currentY;
if (lastType === 'Q' || lastType === 'T') {
reflectedX = currentX + (currentX - lastControlX);
reflectedY = currentY + (currentY - lastControlY);
}
const control1X = currentX + (2 * (reflectedX - currentX)) / 3;
const control1Y = currentY + (2 * (reflectedY - currentY)) / 3;
const control2X = x + (2 * (reflectedX - x)) / 3;
const control2Y = y + (2 * (reflectedY - y)) / 3;
output.push({
key: 'C',
data: [control1X, control1Y, control2X, control2Y, x, y],
});
lastControlX = reflectedX;
lastControlY = reflectedY;
currentX = x;
currentY = y;
break;
}
case 'Q': {
const [controlX, controlY, x, y] = data;
const control1X = currentX + (2 * (controlX - currentX)) / 3;
const control1Y = currentY + (2 * (controlY - currentY)) / 3;
const control2X = x + (2 * (controlX - x)) / 3;
const control2Y = y + (2 * (controlY - y)) / 3;
output.push({
key: 'C',
data: [control1X, control1Y, control2X, control2Y, x, y],
});
lastControlX = controlX;
lastControlY = controlY;
currentX = x;
currentY = y;
break;
}
case 'A': {
const radiusX = Math.abs(data[0]);
const radiusY = Math.abs(data[1]);
const angle = data[2];
const largeArcFlag = data[3];
const sweepFlag = data[4];
const x = data[5];
const y = data[6];
if (radiusX === 0 || radiusY === 0) {
output.push({ key: 'C', data: [currentX, currentY, x, y, x, y] });
currentX = x;
currentY = y;
break;
}
if (currentX !== x || currentY !== y) {
const curves = arcToCubicCurves(
currentX,
currentY,
x,
y,
radiusX,
radiusY,
angle,
largeArcFlag,
sweepFlag
);
for (const curve of curves) {
output.push({ key: 'C', data: curve });
}
currentX = x;
currentY = y;
}
break;
}
case 'Z':
output.push({ key: 'Z', data: [] });
currentX = subpathX;
currentY = subpathY;
break;
}
lastType = key;
}
return output;
}
function tokenize(d) {
const tokens = [];
while (d !== '') {
if (d.match(/^([ \t\r\n,]+)/)) {
d = d.slice(RegExp.$1.length);
} else if (d.match(/^([aAcChHlLmMqQsStTvVzZ])/)) {
tokens.push({ type: COMMAND, text: RegExp.$1 });
d = d.slice(RegExp.$1.length);
} else if (d.match(/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/)) {
tokens.push({ type: NUMBER, text: `${Number.parseFloat(RegExp.$1)}` });
d = d.slice(RegExp.$1.length);
} else {
return [];
}
}
tokens.push({ type: EOD, text: '' });
return tokens;
}
function isType(token, type) {
return token.type === type;
}
function degToRad(degrees) {
return (Math.PI * degrees) / 180;
}
function rotate(x, y, angleRad) {
return [
x * Math.cos(angleRad) - y * Math.sin(angleRad),
x * Math.sin(angleRad) + y * Math.cos(angleRad),
];
}
function arcToCubicCurves(
startX,
startY,
endX,
endY,
radiusX,
radiusY,
angle,
largeArcFlag,
sweepFlag,
recursive
) {
const angleRad = degToRad(angle);
let params = [];
let startAngle = 0;
let endAngle = 0;
let centerX = 0;
let centerY = 0;
if (recursive) {
[startAngle, endAngle, centerX, centerY] = recursive;
} else {
[startX, startY] = rotate(startX, startY, -angleRad);
[endX, endY] = rotate(endX, endY, -angleRad);
const deltaX = (startX - endX) / 2;
const deltaY = (startY - endY) / 2;
let distance =
(deltaX * deltaX) / (radiusX * radiusX) + (deltaY * deltaY) / (radiusY * radiusY);
if (distance > 1) {
distance = Math.sqrt(distance);
radiusX *= distance;
radiusY *= distance;
}
const sign = largeArcFlag === sweepFlag ? -1 : 1;
const radiusXPow = radiusX * radiusX;
const radiusYPow = radiusY * radiusY;
const left =
radiusXPow * radiusYPow - radiusXPow * deltaY * deltaY - radiusYPow * deltaX * deltaX;
const right = radiusXPow * deltaY * deltaY + radiusYPow * deltaX * deltaX;
const factor = sign * Math.sqrt(Math.abs(left / right));
centerX = (factor * radiusX * deltaY) / radiusY + (startX + endX) / 2;
centerY = (-factor * radiusY * deltaX) / radiusX + (startY + endY) / 2;
startAngle = Math.asin(Number.parseFloat(((startY - centerY) / radiusY).toFixed(9)));
endAngle = Math.asin(Number.parseFloat(((endY - centerY) / radiusY).toFixed(9)));
if (startX < centerX) {
startAngle = Math.PI - startAngle;
}
if (endX < centerX) {
endAngle = Math.PI - endAngle;
}
if (startAngle < 0) {
startAngle = Math.PI * 2 + startAngle;
}
if (endAngle < 0) {
endAngle = Math.PI * 2 + endAngle;
}
if (sweepFlag && startAngle > endAngle) {
startAngle -= Math.PI * 2;
}
if (!sweepFlag && endAngle > startAngle) {
endAngle -= Math.PI * 2;
}
}
let angleDelta = endAngle - startAngle;
if (Math.abs(angleDelta) > (Math.PI * 120) / 180) {
const previousEndAngle = endAngle;
const previousEndX = endX;
const previousEndY = endY;
if (sweepFlag && endAngle > startAngle) {
endAngle = startAngle + ((Math.PI * 120) / 180) * 1;
} else {
endAngle = startAngle + ((Math.PI * 120) / 180) * -1;
}
endX = centerX + radiusX * Math.cos(endAngle);
endY = centerY + radiusY * Math.sin(endAngle);
params = arcToCubicCurves(
endX,
endY,
previousEndX,
previousEndY,
radiusX,
radiusY,
angle,
0,
sweepFlag,
[endAngle, previousEndAngle, centerX, centerY]
);
}
angleDelta = endAngle - startAngle;
const cosineStart = Math.cos(startAngle);
const sineStart = Math.sin(startAngle);
const cosineEnd = Math.cos(endAngle);
const sineEnd = Math.sin(endAngle);
const tangent = Math.tan(angleDelta / 4);
const controlX = (4 / 3) * radiusX * tangent;
const controlY = (4 / 3) * radiusY * tangent;
const point1 = [startX, startY];
const point2 = [startX + controlX * sineStart, startY - controlY * cosineStart];
const point3 = [endX + controlX * sineEnd, endY - controlY * cosineEnd];
const point4 = [endX, endY];
point2[0] = 2 * point1[0] - point2[0];
point2[1] = 2 * point1[1] - point2[1];
if (recursive) {
return [point2, point3, point4].concat(params);
}
params = [point2, point3, point4].concat(params);
const curves = [];
for (let index = 0; index < params.length; index += 3) {
const rotated1 = rotate(params[index][0], params[index][1], angleRad);
const rotated2 = rotate(params[index + 1][0], params[index + 1][1], angleRad);
const rotated3 = rotate(params[index + 2][0], params[index + 2][1], angleRad);
curves.push([rotated1[0], rotated1[1], rotated2[0], rotated2[1], rotated3[0], rotated3[1]]);
}
return curves;
}
+26 -4
View File
@@ -4,6 +4,7 @@ import * as React from 'react';
import { getIconAssetURL, useIcons } from './IconsProvider';
import { getIconStyle } from './getIconStyle';
import { getIconMetrics } from './iconMetrics';
import type { IconName, IconStyle } from './types';
/**
@@ -46,19 +47,25 @@ export const Icon = React.forwardRef(function Icon(
iconStyle: propIconStyle = context.iconStyle,
className = '',
size,
viewBox: propViewBox,
...rest
} = props;
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
const url = getIconAssetURL(context, iconStyle, icon);
const metrics = getIconMetrics(iconStyle, icon);
const maskId = React.useId();
const originalViewBox = metrics?.originalViewBox;
const safeViewBox = metrics?.safeViewBox;
return (
<svg
ref={ref}
{...rest}
viewBox={propViewBox ?? (originalViewBox ? originalViewBox.join(' ') : undefined)}
style={{
...(size ? { width: size, height: size } : {}),
...(metrics ? { overflow: 'visible' } : {}),
...rest.style,
}}
className={`gb-icon ${className}`}
@@ -67,6 +74,12 @@ export const Icon = React.forwardRef(function Icon(
<defs>
<mask
id={maskId}
maskUnits={metrics ? 'userSpaceOnUse' : undefined}
maskContentUnits={metrics ? 'userSpaceOnUse' : undefined}
x={safeViewBox?.[0]}
y={safeViewBox?.[1]}
width={safeViewBox?.[2]}
height={safeViewBox?.[3]}
style={{
maskType: 'alpha',
}}
@@ -74,13 +87,22 @@ export const Icon = React.forwardRef(function Icon(
<image
data-testid="mask-image"
href={url}
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
x={safeViewBox?.[0] ?? 0}
y={safeViewBox?.[1] ?? 0}
width={safeViewBox?.[2] ?? '100%'}
height={safeViewBox?.[3] ?? '100%'}
preserveAspectRatio={metrics ? 'none' : 'xMidYMid meet'}
/>
</mask>
</defs>
<rect width="100%" height="100%" fill="currentColor" mask={`url(#${maskId})`} />
<rect
x={safeViewBox?.[0] ?? 0}
y={safeViewBox?.[1] ?? 0}
width={safeViewBox?.[2] ?? '100%'}
height={safeViewBox?.[3] ?? '100%'}
fill="currentColor"
mask={`url(#${maskId})`}
/>
</svg>
);
});
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'bun:test';
import { collectNormalizedIconAssets, createMetricsManifest } from '../bin/icon-assets.js';
import { getKitPath } from '../bin/kit.js';
const regularAssetsPromise = collectNormalizedIconAssets(getKitPath(), ['regular']);
async function getRegularAsset(icon: string) {
const assets = await regularAssetsPromise;
const asset = assets.find((candidate) => candidate.icon === icon);
if (!asset) {
throw new Error(`Missing regular asset for "${icon}"`);
}
return asset;
}
describe('icon asset normalization', () => {
it('keeps the original viewBox while expanding jar to include the overshoot', async () => {
const jar = await getRegularAsset('jar');
expect(jar.originalViewBox).toEqual([0, 0, 320, 512]);
expect(jar.safeViewBox).toEqual([0, -32, 320, 544]);
expect(jar.svg).toContain('viewBox="0 -32 320 544"');
});
it('captures diagonal overflow for arrow-archery', async () => {
const arrowArchery = await getRegularAsset('arrow-archery');
expect(arrowArchery.originalViewBox).toEqual([0, 0, 576, 512]);
expect(arrowArchery.safeViewBox).toEqual([0, -39.9928, 584.5055, 583.8523]);
});
it('only emits metrics for icons that need adjusted bounds', async () => {
const manifest = createMetricsManifest(await regularAssetsPromise);
expect(manifest['regular/jar']).toEqual({
originalViewBox: [0, 0, 320, 512],
safeViewBox: [0, -32, 320, 544],
});
expect(manifest['regular/circle-info']).toBeUndefined();
});
});
+17
View File
@@ -0,0 +1,17 @@
import rawMetrics from './data/metrics.json' with { type: 'json' };
type IconViewBox = [number, number, number, number];
type IconMetrics = {
originalViewBox: IconViewBox;
safeViewBox: IconViewBox;
};
const iconMetrics = rawMetrics as unknown as Record<string, IconMetrics>;
/**
* Lookup the safe rendering metrics for a given icon asset.
*/
export function getIconMetrics(style: string, icon: string): IconMetrics | null {
return iconMetrics[`${style}/${icon}`] ?? null;
}