3 changed files with 142 additions and 106 deletions
@ -1,125 +1,132 @@ |
|||
import { globSync } from 'glob' |
|||
import { readFileSync, writeFileSync } from 'fs' |
|||
import { join, basename } from 'path' |
|||
import { pathToFileURL } from 'url' |
|||
import { optimizePath, removeClosePath, ICONS_SRC_DIR, iconTemplate, types } from './helpers.mjs' |
|||
|
|||
types.forEach(type => { |
|||
const files = globSync(join(ICONS_SRC_DIR, type, '*.svg')) |
|||
export const optimizeSvgContent = (svgFileContent, type) => { |
|||
// Optimize SVG
|
|||
svgFileContent = svgFileContent.replace(/><\/(polyline|line|rect|circle|path|ellipse)>/g, '/>') |
|||
.replace(/rx="([^"]+)"\s+ry="\1"/g, 'rx="$1"') |
|||
.replace(/<path stroke="red" stroke-width="\.1"([^>]+)?\/>/g, '') |
|||
.replace(/<path[^>]+d="M0 0h24v24h-24z"[^>]+\/>/g, '') |
|||
.replace(/\s?\/>/g, ' />') |
|||
.replace(/\n\s*<(line|circle|path|polyline|rect|ellipse)/g, '\n <$1') |
|||
// .replace(/polyline points="([0-9.]+)\s([0-9.]+)\s([0-9.]+)\s([0-9.]+)"/g, 'line x1="$1" y1="$2" x2="$3" y2="$4"')
|
|||
.replace(/<line x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)"\s*\/>/g, function (f, x1, y1, x2, y2) { |
|||
return `<path d="M${x1} ${y1}L${x2} ${y2}" />` |
|||
}) |
|||
.replace(/<circle cx="([^"]+)" cy="([^"]+)" r="([^"]+)"([^>]*)?\/>/g, function (f, cx, cy, r, attrs) { |
|||
return `<path d="M ${cx - r} ${cy}a ${r} ${r} 0 1 0 ${r * 2} 0a ${r} ${r} 0 1 0 ${r * -2} 0"${attrs}/>` |
|||
}) |
|||
.replace(/<ellipse cx="([^"]+)" cy="([^"]+)" rx="([^"]+)"\s+\/>/g, function (f, cx, cy, rx) { |
|||
return `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${rx}" />` |
|||
}) |
|||
.replace(/<ellipse cx="([^"]+)" cy="([^"]+)" rx="([^"]+)" ry="([^"]+)"\s+\/>/g, function (f, cx, cy, rx, ry) { |
|||
return `<path d="M${cx} ${cy}m -${rx} 0a${rx} ${ry} 0 1 0 ${rx * 2} 0a ${rx} ${ry} 0 1 0 -${rx * 2} 0" />` |
|||
}) |
|||
.replace(/<rect width="([^"]+)" height="([^"]+)" x="([^"]+)" y="([^"]+)"(.*)?\/>/g, function (f, width, height, x, y, attrs) { |
|||
return `<rect x="${x}" y="${y}" width="${width}" height="${height}"${attrs} />` |
|||
}) |
|||
.replace(/<rect x="([^"]+)" y="([^"]+)" rx="([^"]+)" width="([^"]+)" height="([^"]+)"\s+\/>/g, function (f, x, y, rx, width, height) { |
|||
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${rx}" />` |
|||
}) |
|||
.replace(/<rect x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)" rx="([^"]+)"\s+\/>/g, function (f, x, y, width, height, rx) { |
|||
return `<path d="M ${x} ${y}m 0 ${rx}a${rx} ${rx} 0 0 1 ${rx} ${-rx}h${width - rx * 2}a${rx} ${rx} 0 0 1 ${rx} ${rx}v${height - rx * 2}a${rx} ${rx} 0 0 1 ${-rx} ${rx}h${-width + rx * 2}a${rx} ${rx} 0 0 1 ${-rx} ${-rx}v${-height + rx * 2}" />` |
|||
}) |
|||
.replace(/<rect x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)"\s+\/>/g, function (f, x, y, width, height) { |
|||
return `<path d="M ${x} ${y}h${width}v${height}h${-width}v${-height}" />` |
|||
}) |
|||
.replace(/<polyline points="([^"]+)\s?"\s+\/>/g, function (f, points) { |
|||
const path = points.split(' ').reduce( |
|||
(accumulator, currentValue, currentIndex) => `${accumulator}${currentIndex % 2 === 0 ? (currentIndex === 0 ? 'M' : 'L') : ''}${currentValue} `, |
|||
'' |
|||
) |
|||
return `<path d="${path}" />` |
|||
}) |
|||
.replace(/<path d="([^"]+)"/g, function (f, r1) { |
|||
// outline icons must not contain closepath — drop a redundant z or
|
|||
// replace it with an explicit closing line
|
|||
if (type === 'outline' && /[zZ]/.test(r1)) { |
|||
r1 = removeClosePath(r1) |
|||
} |
|||
|
|||
files.forEach(function (file, i) { |
|||
console.log(`Optimize ${basename(file)}`); |
|||
r1 = optimizePath(r1) |
|||
|
|||
// Read files
|
|||
let svgFile = readFileSync(file), |
|||
svgFileContent = svgFile.toString() |
|||
return `<path d="${r1}"` |
|||
}) |
|||
.replace(/<path\s+d="([^"]+)"/g, function (f, d) { |
|||
const d2 = d |
|||
.replace(/m0 0/g, (f, m) => ``) |
|||
.replace(/ 0\./g, ' .') |
|||
.replace(/ -0\./g, ' -.') |
|||
.replace(/([amcvhslAMCVHLS]) /g, '$1') |
|||
|
|||
// Optimize SVG
|
|||
svgFileContent = svgFileContent.replace(/><\/(polyline|line|rect|circle|path|ellipse)>/g, '/>') |
|||
.replace(/rx="([^"]+)"\s+ry="\1"/g, 'rx="$1"') |
|||
.replace(/<path stroke="red" stroke-width="\.1"([^>]+)?\/>/g, '') |
|||
.replace(/<path[^>]+d="M0 0h24v24h-24z"[^>]+\/>/g, '') |
|||
.replace(/\s?\/>/g, ' />') |
|||
.replace(/\n\s*<(line|circle|path|polyline|rect|ellipse)/g, '\n <$1') |
|||
// .replace(/polyline points="([0-9.]+)\s([0-9.]+)\s([0-9.]+)\s([0-9.]+)"/g, 'line x1="$1" y1="$2" x2="$3" y2="$4"')
|
|||
.replace(/<line x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)"\s*\/>/g, function (f, x1, y1, x2, y2) { |
|||
return `<path d="M${x1} ${y1}L${x2} ${y2}" />` |
|||
}) |
|||
.replace(/<circle cx="([^"]+)" cy="([^"]+)" r="([^"]+)"([^>]*)?\/>/g, function (f, cx, cy, r, attrs) { |
|||
return `<path d="M ${cx - r} ${cy}a ${r} ${r} 0 1 0 ${r * 2} 0a ${r} ${r} 0 1 0 ${r * -2} 0"${attrs}/>` |
|||
}) |
|||
.replace(/<ellipse cx="([^"]+)" cy="([^"]+)" rx="([^"]+)"\s+\/>/g, function (f, cx, cy, rx) { |
|||
return `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${rx}" />` |
|||
}) |
|||
.replace(/<ellipse cx="([^"]+)" cy="([^"]+)" rx="([^"]+)" ry="([^"]+)"\s+\/>/g, function (f, cx, cy, rx, ry) { |
|||
return `<path d="M${cx} ${cy}m -${rx} 0a${rx} ${ry} 0 1 0 ${rx * 2} 0a ${rx} ${ry} 0 1 0 -${rx * 2} 0" />` |
|||
}) |
|||
.replace(/<rect width="([^"]+)" height="([^"]+)" x="([^"]+)" y="([^"]+)"(.*)?\/>/g, function (f, width, height, x, y, attrs) { |
|||
return `<rect x="${x}" y="${y}" width="${width}" height="${height}"${attrs} />` |
|||
}) |
|||
.replace(/<rect x="([^"]+)" y="([^"]+)" rx="([^"]+)" width="([^"]+)" height="([^"]+)"\s+\/>/g, function (f, x, y, rx, width, height) { |
|||
return `<rect x="${x}" y="${y}" width="${height}" height="${height}" rx="${rx}" />` |
|||
}) |
|||
.replace(/<rect x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)" rx="([^"]+)"\s+\/>/g, function (f, x, y, width, height, rx) { |
|||
return `<path d="M ${x} ${y}m 0 ${rx}a${rx} ${rx} 0 0 1 ${rx} ${-rx}h${width - rx * 2}a${rx} ${rx} 0 0 1 ${rx} ${rx}v${height - rx * 2}a${rx} ${rx} 0 0 1 ${-rx} ${rx}h${-width + rx * 2}a${rx} ${rx} 0 0 1 ${-rx} ${-rx}v${-height + rx * 2}" />` |
|||
}) |
|||
.replace(/<rect x="([^"]+)" y="([^"]+)" width="([^"]+)" height="([^"]+)"\s+\/>/g, function (f, x, y, width, height) { |
|||
return `<path d="M ${x} ${y}h${width}v${height}h${-width}v${-height}" />` |
|||
}) |
|||
.replace(/<polyline points="([^"]+)\s?"\s+\/>/g, function (f, points) { |
|||
const path = points.split(' ').reduce( |
|||
(accumulator, currentValue, currentIndex) => `${accumulator}${currentIndex % 2 === 0 ? (currentIndex === 0 ? 'M' : 'L') : ''}${currentValue} `, |
|||
'' |
|||
) |
|||
return `<path d="${path}" />` |
|||
}) |
|||
.replace(/<path d="([^"]+)"/g, function (f, r1) { |
|||
// outline icons must not contain closepath — drop a redundant z or
|
|||
// replace it with an explicit closing line
|
|||
if (file.match(/\/outline\//) && /[zZ]/.test(r1)) { |
|||
r1 = removeClosePath(r1) |
|||
} |
|||
return `<path d="${d2}"` |
|||
}) |
|||
.replace(/d="m/g, 'd="M') |
|||
.replace(/([Aa])\s?([0-9.]+)[\s,]([0-9.]+)[\s,]([0-9.]+)[\s,]?([0-1])[\s,]?([0-1])[\s,]?(-?[0-9.]+)[\s,]?(-?[0-9.]+)/gi, '$1$2 $3 $4 $5 $6 $7 $8') |
|||
.replace(/<path[^>]*d=["']([^"']*?)a([\d.]+)\s+([\d.]+)\s([01])\s([01])\s([01]+)\s([0-9.-]+)\s([0-9.-]+)a\2\s+\3\s+([01])\s+([01])\s([01]+)\s([0-9.-]+)\s([0-9.-]+)z([^"']*?)["']\s+\/>/g, function (match, d, rx, ry, flag1, flag2, extra1, x1, y1, flag3, flag4, extra2, x2, y2, afterZ) { |
|||
return `<path d="${d}a${rx} ${ry} ${flag1} ${flag2} ${extra1} ${x1} ${y1}a${rx} ${ry} ${flag3} ${flag4} ${extra2} ${x2} ${y2}${afterZ}" />` |
|||
}) |
|||
.replace(/<path[^>]*d=["']([^"']*?)M([0-9.-]+)\s([0-9.-]+)m([0-9.-]+)\s([0-9.-]+)([^"']*?)["'](.*)?\/>/g, function (match, d, x1, y1, x2, y2, afterM, attrs) { |
|||
return `<path d="${d}M${Number(x1) + Number(x2)} ${Number(y1) + Number(y2)}${afterM}"${attrs} />` |
|||
}) |
|||
.replace(/\n\s+\n+/g, '\n') |
|||
.replace(/" +\/>/g, '" />') |
|||
.replace(/<path d="([^"]+)"/g, function (f, d) { |
|||
const d2 = d |
|||
.replace(/v0/g, (f, v) => ``) |
|||
.replace(/h0/g, (f, h) => ``) |
|||
|
|||
r1 = optimizePath(r1) |
|||
return `<path d="${d2}"` |
|||
}) |
|||
|
|||
return `<path d="${r1}"` |
|||
}) |
|||
.replace(/<path\s+d="([^"]+)"/g, function (f, d) { |
|||
const d2 = d |
|||
.replace(/m0 0/g, (f, m) => ``) |
|||
.replace(/ 0\./g, ' .') |
|||
.replace(/ -0\./g, ' -.') |
|||
.replace(/([amcvhslAMCVHLS]) /g, '$1') |
|||
// Add icon template
|
|||
svgFileContent = svgFileContent.replace(/<svg[^>]+>/, iconTemplate(type)) |
|||
|
|||
return `<path d="${d2}"` |
|||
}) |
|||
.replace(/d="m/g, 'd="M') |
|||
.replace(/([Aa])\s?([0-9.]+)[\s,]([0-9.]+)[\s,]([0-9.]+)[\s,]?([0-1])[\s,]?([0-1])[\s,]?(-?[0-9.]+)[\s,]?(-?[0-9.]+)/gi, '$1$2 $3 $4 $5 $6 $7 $8') |
|||
.replace(/<path[^>]*d=["']([^"']*?)a([\d.]+)\s+([\d.]+)\s([01])\s([01])\s([01]+)\s([0-9.-]+)\s([0-9.-]+)a\2\s+\3\s+([01])\s+([01])\s([01]+)\s([0-9.-]+)\s([0-9.-]+)z([^"']*?)["']\s+\/>/g, function (match, d, rx, ry, flag1, flag2, extra1, x1, y1, flag3, flag4, extra2, x2, y2, afterZ) { |
|||
return `<path d="${d}a${rx} ${ry} ${flag1} ${flag2} ${extra1} ${x1} ${y1}a${rx} ${ry} ${flag3} ${flag4} ${extra2} ${x2} ${y2}${afterZ}" />` |
|||
}) |
|||
.replace(/<path[^>]*d=["']([^"']*?)M([0-9.-]+)\s([0-9.-]+)m([0-9.-]+)\s([0-9.-]+)([^"']*?)["'](.*)?\/>/g, function (match, d, x1, y1, x2, y2, afterM, attrs) { |
|||
return `<path d="${d}M${Number(x1) + Number(x2)} ${Number(y1) + Number(y2)}${afterM}"${attrs} />` |
|||
}) |
|||
.replace(/\n\s+\n+/g, '\n') |
|||
.replace(/" +\/>/g, '" />') |
|||
.replace(/<path d="([^"]+)"/g, function (f, d) { |
|||
const d2 = d |
|||
.replace(/v0/g, (f, v) => ``) |
|||
.replace(/h0/g, (f, h) => ``) |
|||
// Remove stroke and fill
|
|||
if (type === 'filled') { |
|||
svgFileContent = svgFileContent |
|||
.replace(/stroke-width="0" fill="currentColor"/gm, '') |
|||
.replace('stroke-width="2"', '') |
|||
.replace('stroke-linecap="round"', '') |
|||
.replace('stroke-linejoin="round"', '') |
|||
.replace('stroke="currentColor"', '') |
|||
.replace('fill="none"', 'fill="currentColor"') |
|||
.replace(/^\s*[\r\n]/gm, '') |
|||
.replace(/\s{2,}\//g, ' /') |
|||
} |
|||
|
|||
return `<path d="${d2}"` |
|||
}) |
|||
// Add comment if not exists
|
|||
if (!svgFileContent.includes('<!--')) { |
|||
svgFileContent = '<!--\n-->\n' + svgFileContent |
|||
} |
|||
|
|||
// Add icon template
|
|||
svgFileContent = svgFileContent.replace(/<svg[^>]+>/, iconTemplate(type)) |
|||
svgFileContent = svgFileContent.replace(/tags: \[([^]+)\]/, (m, tags) => { |
|||
tags = [...new Set(tags.split(',').map(t => t.trim()))].filter(t => t).join(', ') |
|||
|
|||
// Remove stroke and fill
|
|||
if (file.match(/\/filled\//)) { |
|||
svgFileContent = svgFileContent |
|||
.replace(/stroke-width="0" fill="currentColor"/gm, '') |
|||
.replace('stroke-width="2"', '') |
|||
.replace('stroke-linecap="round"', '') |
|||
.replace('stroke-linejoin="round"', '') |
|||
.replace('stroke="currentColor"', '') |
|||
.replace('fill="none"', 'fill="currentColor"') |
|||
.replace(/^\s*[\r\n]/gm, '') |
|||
.replace(/\s{2,}\//g, ' /') |
|||
} |
|||
return `tags: [${tags}]` |
|||
}) |
|||
|
|||
// Add comment if not exists
|
|||
if (!svgFileContent.includes('<!--')) { |
|||
svgFileContent = '<!--\n-->\n' + svgFileContent |
|||
} |
|||
return svgFileContent |
|||
} |
|||
|
|||
svgFileContent = svgFileContent.replace(/tags: \[([^]+)\]/, (m, tags) => { |
|||
tags = [...new Set(tags.split(',').map(t => t.trim()))].filter(t => t).join(', ') |
|||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
|||
types.forEach(type => { |
|||
const files = globSync(join(ICONS_SRC_DIR, type, '*.svg')) |
|||
|
|||
return `tags: [${tags}]` |
|||
}) |
|||
files.forEach(function (file) { |
|||
console.log(`Optimize ${basename(file)}`); |
|||
|
|||
// Write files
|
|||
if (svgFile.toString() !== svgFileContent) { |
|||
writeFileSync(file, svgFileContent) |
|||
} |
|||
// Read files
|
|||
let svgFile = readFileSync(file), |
|||
svgFileContent = optimizeSvgContent(svgFile.toString(), type) |
|||
|
|||
// Write files
|
|||
if (svgFile.toString() !== svgFileContent) { |
|||
writeFileSync(file, svgFileContent) |
|||
} |
|||
}) |
|||
}) |
|||
}) |
|||
} |
|||
|
|||
@ -0,0 +1,28 @@ |
|||
import { test } from 'node:test' |
|||
import assert from 'node:assert/strict' |
|||
import { optimizeSvgContent } from './optimize.mjs' |
|||
|
|||
const svg = (body) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> |
|||
${body} |
|||
</svg>` |
|||
|
|||
test('rect with x/y/rx/width/height attribute order keeps its width', () => { |
|||
// regression for the bug that squared the ear cups of `headphones` and
|
|||
// `headset` (issue #482): width was replaced by the height capture
|
|||
const result = optimizeSvgContent(svg('<rect x="4" y="13" rx="2" width="5" height="7" />'), 'outline') |
|||
|
|||
assert.match(result, /<path d="M4 15a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2v-3" \/>/) |
|||
}) |
|||
|
|||
test('rect with width/height/x/y attribute order keeps its width', () => { |
|||
const result = optimizeSvgContent(svg('<rect width="5" height="7" x="4" y="13" rx="2" />'), 'outline') |
|||
|
|||
assert.match(result, /<path d="M4 15a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2v-3" \/>/) |
|||
}) |
|||
|
|||
test('rect without rx becomes an unclosed rectangle path', () => { |
|||
const result = optimizeSvgContent(svg('<rect x="4" y="13" width="5" height="7" />'), 'outline') |
|||
|
|||
assert.match(result, /<path d="M4 13h5v7h-5v-7" \/>/) |
|||
}) |
|||
Loading…
Reference in new issue