Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions projects/core/.visual/format-bytes.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions projects/core/.visual/format-bytes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions projects/core/.visual/icon-button.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions projects/core/.visual/icon-button.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions projects/core/.visual/icon.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions projects/core/.visual/icon.explicit-optical-spacing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions projects/core/.visual/icon.no-ghost-space.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions projects/core/.visual/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions projects/core/.visual/icon.solid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
301 changes: 156 additions & 145 deletions projects/core/build/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const frequentIcons = [
'double-chevron'
];

const preservedAspectRatioIcons = new Set(['split-horizontal', 'split-none', 'split-vertical', 'stop']);

let icons = readIconFiles();
validateIconSVGs(icons);
icons = await repairViewBoxScales(icons);
Expand Down Expand Up @@ -232,167 +234,176 @@ async function repairViewBoxScales(svgs) {
const context = await browser.newContext();
const page = await context.newPage();

const result = await page.evaluate(async icons => {
const graphicsSelector = 'circle, ellipse, image, line, path, polygon, polyline, rect, text, use';
const maxRasterSize = 4096;
const rasterScale = 64;
const result = await page.evaluate(
async ({ icons, authoredViewBoxIconNames }) => {
const preservedAspectRatioIcons = new Set(authoredViewBoxIconNames);
const graphicsSelector = 'circle, ellipse, image, line, path, polygon, polyline, rect, text, use';
const maxRasterSize = 4096;
const rasterScale = 64;

function getGeometryBounds(svg) {
const { x, y, width, height } = svg.getBBox();
return { x, y, width, height };
}
function getGeometryBounds(svg) {
const { x, y, width, height } = svg.getBBox();
return { x, y, width, height };
}

function getSourceViewBox(svg, geometryBounds) {
const { x, y, width, height } = svg.viewBox.baseVal;
if (width > 0 && height > 0) return { x, y, width, height };
function getSourceViewBox(svg, geometryBounds) {
const { x, y, width, height } = svg.viewBox.baseVal;
if (width > 0 && height > 0) return { x, y, width, height };

const sourceWidth = Number.parseFloat(svg.getAttribute('width'));
const sourceHeight = Number.parseFloat(svg.getAttribute('height'));
return sourceWidth > 0 && sourceHeight > 0
? { x: 0, y: 0, width: sourceWidth, height: sourceHeight }
: geometryBounds;
}
const sourceWidth = Number.parseFloat(svg.getAttribute('width'));
const sourceHeight = Number.parseFloat(svg.getAttribute('height'));
return sourceWidth > 0 && sourceHeight > 0
? { x: 0, y: 0, width: sourceWidth, height: sourceHeight }
: geometryBounds;
}

function hasVisibleStroke(svg) {
return [...svg.querySelectorAll(graphicsSelector)].some(element => {
const style = getComputedStyle(element);
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.stroke !== 'none' &&
Number.parseFloat(style.strokeWidth) > 0 &&
Number.parseFloat(style.strokeOpacity) > 0
);
});
}
function hasVisibleStroke(svg) {
return [...svg.querySelectorAll(graphicsSelector)].some(element => {
const style = getComputedStyle(element);
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.stroke !== 'none' &&
Number.parseFloat(style.strokeWidth) > 0 &&
Number.parseFloat(style.strokeOpacity) > 0
);
});
}

async function getPaintedBounds(svg, sourceViewBox) {
const scale = Math.min(rasterScale, maxRasterSize / Math.max(sourceViewBox.width, sourceViewBox.height));
const width = Math.max(1, Math.ceil(sourceViewBox.width * scale));
const height = Math.max(1, Math.ceil(sourceViewBox.height * scale));
const scaleX = width / sourceViewBox.width;
const scaleY = height / sourceViewBox.height;
const clone = svg.cloneNode(true);

clone.setAttribute('width', width);
clone.setAttribute('height', height);
clone.setAttribute('preserveAspectRatio', 'none');
clone.style.setProperty('color', '#000');

const url = URL.createObjectURL(new Blob([clone.outerHTML], { type: 'image/svg+xml' }));
const image = new Image();
image.src = url;

try {
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
let maxAlpha = 0;
for (let pixel = 0; pixel < width * height; pixel++) {
maxAlpha = Math.max(maxAlpha, pixels[pixel * 4 + 3]);
}
if (maxAlpha === 0) return null;

// Ignore low-alpha antialiasing fringes while retaining relative opacity for faint icons.
const alphaThreshold = maxAlpha * 0.25;
let xMin = width;
let xMax = -1;
let yMin = height;
let yMax = -1;

for (let pixel = 0; pixel < width * height; pixel++) {
if (pixels[pixel * 4 + 3] < alphaThreshold) continue;
const x = pixel % width;
const y = Math.floor(pixel / width);
xMin = Math.min(xMin, x);
xMax = Math.max(xMax, x);
yMin = Math.min(yMin, y);
yMax = Math.max(yMax, y);
async function getPaintedBounds(svg, sourceViewBox) {
const scale = Math.min(rasterScale, maxRasterSize / Math.max(sourceViewBox.width, sourceViewBox.height));
const width = Math.max(1, Math.ceil(sourceViewBox.width * scale));
const height = Math.max(1, Math.ceil(sourceViewBox.height * scale));
const scaleX = width / sourceViewBox.width;
const scaleY = height / sourceViewBox.height;
const clone = svg.cloneNode(true);

clone.setAttribute('width', width);
clone.setAttribute('height', height);
clone.setAttribute('preserveAspectRatio', 'none');
clone.style.setProperty('color', '#000');

const url = URL.createObjectURL(new Blob([clone.outerHTML], { type: 'image/svg+xml' }));
const image = new Image();
image.src = url;

try {
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
let maxAlpha = 0;
for (let pixel = 0; pixel < width * height; pixel++) {
maxAlpha = Math.max(maxAlpha, pixels[pixel * 4 + 3]);
}
if (maxAlpha === 0) return null;

// Ignore low-alpha antialiasing fringes while retaining relative opacity for faint icons.
const alphaThreshold = maxAlpha * 0.25;
let xMin = width;
let xMax = -1;
let yMin = height;
let yMax = -1;

for (let pixel = 0; pixel < width * height; pixel++) {
if (pixels[pixel * 4 + 3] < alphaThreshold) continue;
const x = pixel % width;
const y = Math.floor(pixel / width);
xMin = Math.min(xMin, x);
xMax = Math.max(xMax, x);
yMin = Math.min(yMin, y);
yMax = Math.max(yMax, y);
}

return xMax < xMin || yMax < yMin
? null
: {
x: sourceViewBox.x + xMin / scaleX,
y: sourceViewBox.y + yMin / scaleY,
width: (xMax - xMin + 1) / scaleX,
height: (yMax - yMin + 1) / scaleY
};
} finally {
URL.revokeObjectURL(url);
}
}

return xMax < xMin || yMax < yMin
? null
: {
x: sourceViewBox.x + xMin / scaleX,
y: sourceViewBox.y + yMin / scaleY,
width: (xMax - xMin + 1) / scaleX,
height: (yMax - yMin + 1) / scaleY
};
} finally {
URL.revokeObjectURL(url);
function formatViewBox(bounds) {
const precision = 100;
const x = Math.floor(bounds.x * precision) / precision;
const y = Math.floor(bounds.y * precision) / precision;
const xMax = Math.ceil((bounds.x + bounds.width) * precision) / precision;
const yMax = Math.ceil((bounds.y + bounds.height) * precision) / precision;
return [x, y, xMax - x, yMax - y].map(value => +value.toFixed(2)).join(' ');
}
}

function formatViewBox(bounds) {
const precision = 100;
const x = Math.floor(bounds.x * precision) / precision;
const y = Math.floor(bounds.y * precision) / precision;
const xMax = Math.ceil((bounds.x + bounds.width) * precision) / precision;
const yMax = Math.ceil((bounds.y + bounds.height) * precision) / precision;
return [x, y, xMax - x, yMax - y].map(value => +value.toFixed(2)).join(' ');
}
function fitBoundsToAspectRatio(bounds, source) {
const sourceAspectRatio = source.width / source.height;
const boundsAspectRatio = bounds.width / bounds.height;
let { x, y, width, height } = bounds;

if (boundsAspectRatio > sourceAspectRatio) {
const fittedHeight = width / sourceAspectRatio;
y -= (fittedHeight - height) / 2;
height = fittedHeight;
} else {
const fittedWidth = height * sourceAspectRatio;
x -= (fittedWidth - width) / 2;
width = fittedWidth;
}

function fitBoundsToAspectRatio(bounds, source) {
const sourceAspectRatio = source.width / source.height;
const boundsAspectRatio = bounds.width / bounds.height;
let { x, y, width, height } = bounds;

if (boundsAspectRatio > sourceAspectRatio) {
const fittedHeight = width / sourceAspectRatio;
y -= (fittedHeight - height) / 2;
height = fittedHeight;
} else {
const fittedWidth = height * sourceAspectRatio;
x -= (fittedWidth - width) / 2;
width = fittedWidth;
// Keep the fitted bounds inside the source canvas while retaining all painted pixels.
x = Math.min(Math.max(x, source.x), source.x + source.width - width);
y = Math.min(Math.max(y, source.y), source.y + source.height - height);
return { x, y, width, height };
}

// Keep the fitted bounds inside the source canvas while retaining all painted pixels.
x = Math.min(Math.max(x, source.x), source.x + source.width - width);
y = Math.min(Math.max(y, source.y), source.y + source.height - height);
return { x, y, width, height };
}
function viewBoxesDiffer(source, target) {
const tolerance = 0.025;
return [
source.x - target.x,
source.y - target.y,
source.x + source.width - (target.x + target.width),
source.y + source.height - (target.y + target.height)
].some(difference => Math.abs(difference) > tolerance);
}

function viewBoxesDiffer(source, target) {
const tolerance = 0.025;
return [
source.x - target.x,
source.y - target.y,
source.x + source.width - (target.x + target.width),
source.y + source.height - (target.y + target.height)
].some(difference => Math.abs(difference) > tolerance);
}
const result = {};
for (const [name, icon] of Object.entries(icons)) {
if (preservedAspectRatioIcons.has(name)) {
result[name] = icon;
continue;
}

const result = {};
for (const [name, icon] of Object.entries(icons)) {
const div = document.createElement('div');
document.body.append(div);
try {
div.innerHTML = icon;
const svg = div.querySelector('svg');
const geometryBounds = getGeometryBounds(svg);
const sourceViewBox = getSourceViewBox(svg, geometryBounds);
const requiresPaintedBounds = hasVisibleStroke(svg) || svg.querySelector('mask') !== null;
const paintedBounds = requiresPaintedBounds ? await getPaintedBounds(svg, sourceViewBox) : null;
const targetViewBox = fitBoundsToAspectRatio(paintedBounds ?? geometryBounds, sourceViewBox);
// Keep intrinsic SVG bounds tight without changing the source canvas ratio. Intentional optical spacing
// belongs in the icon's presentation styles.
if (!svg.hasAttribute('viewBox') || viewBoxesDiffer(sourceViewBox, targetViewBox)) {
svg.setAttribute('viewBox', formatViewBox(targetViewBox));
const div = document.createElement('div');
document.body.append(div);
try {
div.innerHTML = icon;
const svg = div.querySelector('svg');
const geometryBounds = getGeometryBounds(svg);
const sourceViewBox = getSourceViewBox(svg, geometryBounds);
const requiresPaintedBounds = hasVisibleStroke(svg) || svg.querySelector('mask') !== null;
const paintedBounds = requiresPaintedBounds ? await getPaintedBounds(svg, sourceViewBox) : null;
const targetViewBox = fitBoundsToAspectRatio(paintedBounds ?? geometryBounds, sourceViewBox);
// Keep intrinsic SVG bounds tight without changing the source canvas ratio. Intentional optical spacing
// belongs in the icon's presentation styles.
if (!svg.hasAttribute('viewBox') || viewBoxesDiffer(sourceViewBox, targetViewBox)) {
svg.setAttribute('viewBox', formatViewBox(targetViewBox));
}
result[name] = div.innerHTML;
} catch (error) {
throw new Error(`Failed to repair viewBox for icon "${name}"`, { cause: error });
} finally {
div.remove();
}
result[name] = div.innerHTML;
} catch (error) {
throw new Error(`Failed to repair viewBox for icon "${name}"`, { cause: error });
} finally {
div.remove();
}
}
return result;
}, svgs);
return result;
},
{ icons: svgs, authoredViewBoxIconNames: [...preservedAspectRatioIcons] }
);
await browser.close();
return result;
}
Loading