quasar-app-extension-custom-extra-icons
Version:
Custom Extra SVG icons for Quasar Framework
568 lines (505 loc) • 19.4 kB
JavaScript
import { DOMParser } from '@xmldom/xmldom'
import { readFileSync, writeFileSync } from 'fs'
import { basename } from 'path'
const Parser = new DOMParser()
const cjsReplaceRE = /export const /g
const typeExceptions = [
'g',
'svg',
'defs',
'style',
'title',
'clipPath',
'desc',
'mask',
'linearGradient',
'radialGradient',
'stop',
'metadata',
'sodipodi:namedview',
'rdf:RDF',
'cc:Work',
'dc:title',
'dc:type',
'dc:format',
'text',
'animate',
'switch',
]
const noChildren = ['clipPath']
// Helper Functions
/**
* Chunks an array into smaller arrays of a specified size.
*
* @param {Array} arr - The array to be chunked.
* @param {number} [size=2] - The size of each chunk.
* @returns {Array[]} - An array of chunked arrays.
*/
const chunkArray = (arr, size = 2) =>
Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
arr.slice(i * size, i * size + size),
)
/**
* Calculates a value based on a given base value.
*
* If the input value ends with a '%', it is treated as a percentage and the
* value is calculated as a percentage of the base value. Otherwise, the input
* value is returned as a number.
*
* @param {string} val - The input value to be calculated.
* @param {number} base - The base value to use for percentage calculations.
* @returns {number} - The calculated value.
*/
const calcValue = (val, base) => (/%$/.test(val) ? (parseFloat(val) * base) / 100 : +val)
/**
* Retrieves the specified attributes from an HTML element and returns them as an object.
*
* @param {Element} el - The HTML element to retrieve the attributes from.
* @param {string[]} list - The list of attribute names to retrieve.
* @returns {Object} - An object containing the specified attributes and their values.
*/
const getAttributes = (el, list) =>
list.reduce(
(attrs, name) => ({
...attrs,
[name]: parseFloat(el.getAttribute(name) || 0),
}),
{},
)
/**
* Recursively retrieves the attributes of an HTML element and its parent elements,
* and returns them as a string of CSS-style attribute-value pairs.
*
* @param {Element} el - The HTML element to retrieve the attributes from.
* @returns {string} - A string of CSS-style attribute-value pairs.
*/
const getRecursiveAttributes = (el) =>
el.parentNode?.attributes
? `${getRecursiveAttributes(el.parentNode)}${getAttributesAsStyle(el)}`
: getAttributesAsStyle(el)
/**
* Retrieves the attributes of an HTML element as a string of CSS-style attribute-value pairs,
* excluding certain predefined attributes.
*
* @param {Element} el - The HTML element to retrieve the attributes from.
* @returns {string} - A string of CSS-style attribute-value pairs.
*/
const getAttributesAsStyle = (el) => {
const exceptions = new Set([
'aria-hidden',
'aria-label',
'aria-labelledby',
'baseProfile',
'class',
'clip-path',
'cx',
'cy',
'd',
'data-du',
'data-name',
'data-tags',
'enable-background',
'focusable',
'height',
'id',
'mask',
'name',
'points',
'r',
'role',
'rx',
'ry',
'style',
'transform',
'version',
'viewBox',
'width',
'x',
'x1',
'x2',
'xml:space',
'xmlns',
'xmlns:xlink',
'y',
'y1',
'y2',
])
return Array.from(el.attributes)
.filter(({ namespaceURI }) => namespaceURI === null)
.filter(({ nodeName }) => !exceptions.has(nodeName))
.map(({ nodeName, nodeValue }) => `${nodeName}:${nodeValue};`)
.join('')
}
/**
* Recursively retrieves the transform attribute values from the given element and its parent elements.
* @param {Element} el - The element to start the recursive search from.
* @returns {string} - The concatenated transform attribute values from the element and its parent elements.
*/
const getRecursiveTransforms = (el) =>
el.parentNode?.attributes
? `${getRecursiveTransforms(el.parentNode)}${el.getAttribute('transform') || ''}`
: el.getAttribute('transform') || ''
// SVG Decoders
/**
* An object containing functions to decode various SVG element types into path data.
* These decoders are used to convert SVG elements into a standardized path format that can be rendered.
*/
const decoders = {
svg: () => '', // Nothing here. This is needed to grab any attributes on svg tag..
path: (el) => {
const points = el.getAttribute('d')?.trim()
if (!points) throw new Error('No points found in path')
return points.startsWith('m') ? 'M0 0z' + points : points
},
circle: (el) => {
const { cx = 0, cy = 0, r } = getAttributes(el, ['cx', 'cy', 'r'])
return `M${cx} ${cy} m-${r}, 0 a${r},${r} 0 1,0 ${r * 2},0 a${r},${r} 0 1,0 ${-r * 2},0`
},
ellipse: (el) => {
const { cx = 0, cy = 0, rx, ry } = getAttributes(el, ['cx', 'cy', 'rx', 'ry'])
return `M${cx - rx},${cy} a${rx},${ry} 0 1,0 ${2 * rx},0 a${rx},${ry} 0 1,0 ${-2 * rx},0Z`
},
polygon: (el) => decoders.polyline(el) + 'z',
polyline: (el) => {
const points = el.getAttribute('points') || ''
const pairs = chunkArray(points.split(/[\s,]+/).filter(Boolean), 2)
return pairs.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x} ${y}`).join(' ')
},
rect: (el) => {
const att = getAttributes(el, ['x', 'y', 'width', 'height', 'rx', 'ry'])
const w = +att.width
const h = +att.height
const x = att.x ? +att.x : 0
const y = att.y ? +att.y : 0
let rx = att.rx || 'auto'
let ry = att.ry || 'auto'
if (rx === 'auto' && ry === 'auto') {
rx = ry = 0
} else if (rx !== 'auto' && ry === 'auto') {
rx = ry = calcValue(rx, w)
} else if (ry !== 'auto' && rx === 'auto') {
ry = rx = calcValue(ry, h)
} else {
rx = calcValue(rx, w)
ry = calcValue(ry, h)
}
if (rx > w / 2) {
rx = w / 2
}
if (ry > h / 2) {
ry = h / 2
}
const hasCurves = rx > 0 && ry > 0
return [
`M${x + rx} ${y}`,
`H${x + w - rx}`,
...(hasCurves ? [`A${rx} ${ry} 0 0 1 ${x + w} ${y + ry}`] : []),
`V${y + h - ry}`,
...(hasCurves ? [`A${rx} ${ry} 0 0 1 ${x + w - rx} ${y + h}`] : []),
`H${x + rx}`,
...(hasCurves ? [`A${rx} ${ry} 0 0 1 ${x} ${y + h - ry}`] : []),
`V${y + ry}`,
...(hasCurves ? [`A${rx} ${ry} 0 0 1 ${x + rx} ${y}`] : []),
'z',
].join(' ')
},
line: (el) => {
const { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } = getAttributes(el, ['x1', 'y1', 'x2', 'y2'])
return `M${x1},${y1}L${x2},${y2}`
},
}
/**
* Parses the DOM of an SVG file and extracts paths, styles, and transformations.
*
* @param {string} name - The name of the SVG.
* @param {HTMLElement} el - The root element of the SVG.
* @param {Array} pathsDefinitions - An array to store the extracted paths, styles, and transformations.
* @param {Object} options - Additional options for parsing.
* @param {Array|Function} options.stylesFilter - Filters to apply to the styles of the SVG elements.
* @param {Function} options.stylesFilter.from - The pattern to replace in the styles.
* @param {Function} options.stylesFilter.to - The replacement pattern for the styles.
* @param {Function} options.viewBoxFilter - A function to filter the viewBox attribute of the SVG.
* @returns {void}
*/
function parseDom(name, el, pathsDefinitions, options) {
const type = el.nodeName
if (el.getAttribute === void 0 || el.getAttribute('opacity') === '0') {
return
}
if (typeExceptions.includes(type) === false) {
if (decoders[type] === void 0) {
console.error(`Encountered unsupported tag: "${type}" in ${name}`)
throw new Error(`Unsupported tag: "${type}" in ${name}`)
}
const style = el.getAttribute('style') || ''
let strAttributes = (style + getRecursiveAttributes(el)).replace(/;;/g, ';')
// any styles filters?
if (options?.stylesFilter) {
if (Array.isArray(options.stylesFilter) && options.stylesFilter.length > 0) {
options.stylesFilter.forEach((filter) => {
strAttributes = strAttributes.replace(filter.from, filter.to)
})
} else if (typeof options.stylesFilter === 'function') {
strAttributes = options.stylesFilter(strAttributes)
}
}
// This must come after filter function above
// don't allow fill to be both 'none' and 'currentColor'
// this is common because of the inheritance of 'fill:none' from an 'svg' tag
if (
strAttributes.indexOf('fill:none;') >= 0 &&
strAttributes.indexOf('fill:currentColor;') >= 0
) {
strAttributes = strAttributes.replace(/fill:none;/, '')
}
const arrAttributes = strAttributes.split(';')
const combinedStyles = new Set(arrAttributes)
const transform = getRecursiveTransforms(el)
const paths = {
path: decoders[type](el),
style: Array.from(combinedStyles).join(';'),
transform: transform,
}
if (paths.path.length > 0) {
pathsDefinitions.push(paths)
}
}
if (noChildren.includes(type) === false) {
Array.from(el.childNodes).forEach((child) => {
parseDom(name, child, pathsDefinitions, options)
})
}
}
/**
* Generates a viewBox string from the width and height attributes of an SVG element.
*
* If the SVG element has both width and height attributes, this function will return a viewBox string in the format "0 0 {width} {height}".
* If the SVG element is missing either the width or height attribute, this function will return an empty string.
*
* @param {Element} el - The SVG element to extract the width and height from.
* @returns {string} The viewBox string, or an empty string if the width or height is missing.
*/
function getWidthHeightAsViewbox(el) {
const att = getAttributes(el, ['width', 'height'])
if (att.width && att.height) {
return `0 0 ${att.width} ${att.height}`
}
return ''
}
/**
* Parses the content of an SVG file and extracts the path definitions, styles, and transforms.
*
* This function takes the name of the SVG file, the content of the SVG file, and optional options object. It returns an object with the following properties:
*
* - `viewBox`: The viewBox string of the SVG, or an empty string if the viewBox is not specified and the width and height attributes are missing.
* - `paths`: A string containing the path definitions, styles, and transforms for the SVG.
*
* The function first parses the SVG content into a DOM document, then extracts the viewBox and calls the `parseDom` function to recursively parse the DOM and extract the path definitions, styles, and transforms. If any errors occur during parsing, the function will throw an error.
*
* @param {string} name - The name of the SVG file.
* @param {string} content - The content of the SVG file.
* @param {object} [options] - An optional options object.
* @param {function} [options.viewBoxFilter] - A function that can be used to filter the viewBox string.
* @returns {object} An object with the `viewBox` and `paths` properties.
*/
function parseSvgContent(name, content, options) {
let viewBox
const pathsDefinitions = []
try {
const dom = Parser.parseFromString(content, 'text/xml')
viewBox = dom.documentElement.getAttribute('viewBox')
if (!viewBox) {
// check if there is width and height
viewBox = getWidthHeightAsViewbox(dom.documentElement)
}
if (viewBox && options?.viewBoxFilter && typeof options.viewBoxFilter === 'function') {
viewBox = options.viewBoxFilter(viewBox)
}
parseDom(name, dom.documentElement, pathsDefinitions, options)
// console.log(content);
} catch (err) {
console.error(`[Error] "${name}" could not be parsed:`, err.message)
// console.error(content);
throw err
}
if (pathsDefinitions.length === 0) {
throw new Error(`Could not infer any paths for "${name}"`)
}
const tmpView = `|${viewBox}`
const result = {
viewBox: viewBox !== '0 0 24 24' && tmpView !== '|' ? tmpView : '',
}
if (pathsDefinitions.every((def) => !def.style && !def.transform)) {
result.paths = pathsDefinitions.map((def) => def.path).join('')
} else {
result.paths = pathsDefinitions
.map((def) => {
let stylePart = def.style ? `@@${def.style}` : '' // Include style only if it is non-empty
let transformPart = def.transform ? `@@${def.transform}` : '' // Include transform only if it is non-empty
// If style is empty but transform is not, we need a special case
if (!def.style && def.transform) {
stylePart = '@@' // Empty style needs to output "@@" when transform exists
}
// Combine path with stylePart and transformPart
return `${def.path}${stylePart}${transformPart}`
})
.join('&&')
}
return result
}
/**
* Generates a name for an SVG icon based on the file path and options.
*
* @param {string} filePath - The file path of the SVG icon.
* @param {string} [prefix] - An optional prefix to prepend to the icon name.
* @param {object} [options] - An optional object with options for name generation.
* @param {function} [options.filterName] - A function to filter the icon name.
* @returns {string} The generated icon name.
*/
export const defaultNameMapper = (filePath, prefix, options) => {
let baseName = basename(filePath, '.svg')
if (baseName.endsWith(' ')) {
console.log(baseName + ' ends with space')
baseName = baseName.trim()
}
if (options?.filterName && typeof options.filterName === 'function') {
baseName = options.filterName(baseName)
if (typeof baseName === 'object' && 'match' in baseName) {
return baseName
}
}
let name = ((prefix ? prefix + '-' : '') + baseName)
.replace(/_|%|\+|\./g, '-')
.replace(/\s|-{2,}/g, '-')
.replace(/(-\w)/g, (m) => m[1].toUpperCase())
if (name.charAt(name.length - 1) === '-' || name.charAt(name.length - 1) === ' ') {
name = name.slice(0, name.length - 1)
}
return name
}
/**
* Extracts and optimizes SVG content from a file.
*
* This function performs the following tasks:
* - Cleans up the SVG content by removing unnecessary whitespace and newline characters.
* - Removes the DOCTYPE declaration, which can cause issues with the XML parser.
* - Applies any pre-filters specified in the `options` object to the SVG content.
* - Checks if the icon is excluded from optimization based on the `options.excluded` list.
* - Optimizes the SVG content using the `parseSvgContent` function.
* - Applies any post-filters specified in the `options` object to the optimized SVG paths.
* - Returns an object containing the optimized SVG definition and type definition.
*
* @param {string} content - The SVG content to be extracted and optimized.
* @param {string} name - The name of the SVG icon.
* @param {object} [options] - An optional object with options for the extraction and optimization process.
* @param {function|object[]} [options.preFilters] - A function or an array of objects with `from` and `to` properties to apply as pre-filters.
* @param {string[]} [options.excluded] - An array of icon names to exclude from optimization.
* @param {function|object[]} [options.postFilters] - A function or an array of objects with `from` and `to` properties to apply as post-filters.
* @returns {object} An object containing the optimized SVG definition and type definition.
*/
function extractSvg(content, name, options = {}) {
// Why is it some SVG has something like this? 'height="2""' - a pain!
// Fix it up for the parser. Seems to be an Icomoon/Inkscape issue.
// Another found: '<rect" x="14"'
content = content
.replace(/"2""/g, '"2"')
.replace(/ "/g, '"')
.replace(/rect" /g, 'rect ')
// any svg preFilters?
if (options?.preFilters) {
if (typeof options.preFilters === 'function') {
content = options.preFilters(name, content)
} else if (options.preFilters.length > 0) {
options.preFilters.forEach((filter) => {
content = content.replace(filter.from, filter.to)
})
}
}
// any excluded icons from SVGO?
// let isExcluded = false
// if (options?.excluded && options.excluded.length > 0) {
// isExcluded = options.excluded.includes(name)
// }
let result
// if (!isExcluded) {
// const { data } = optimize(content, {
// plugins: [
// {
// name: 'preset-default',
// params: {
// overrides: {
// removeViewBox: false
// }
// }
// }
// ]
// })
// result = data
// }
const optimizedSvgContent = result || content
const { paths, viewBox } = parseSvgContent(name, optimizedSvgContent, options)
let paths2 = paths
// any svg postFilters?
if (options?.postFilters) {
if (Array.isArray(options.postFilters) && options.postFilters.length > 0) {
options.postFilters.forEach((filter) => {
paths2 = paths2.replace(filter.from, filter.to)
})
} else if (typeof options.postFilters === 'function') {
paths2 = options.postFilters(paths2)
}
}
const path = paths2
.replace(/[\r\n]+/gi, ',')
.replace(/\s+/g, ' ') // multiple whitespace with 1 space
// .replace(/\t/g, ' ')
.replace(/,,/gi, ',')
.replace(/, /gi, ' ')
.replace(/ z/g, 'z')
.replace(/fill:none;fill:currentColor;/g, 'fill:currentColor;')
return {
svgDef: `export const ${name} = '${path}${viewBox}'`,
typeDef: `export declare const ${name}: string;`,
}
}
export { extractSvg }
/**
* Extracts SVG content from a file and performs cleanup on the SVG.
*
* @param {string} filePath - The file path of the SVG file.
* @param {string} name - The name of the SVG icon.
* @param {object} options - Additional options for processing the SVG.
* @returns {object} - An object containing the SVG definition and type definition.
*/
export const extract = (filePath, name, options) => {
let content = readFileSync(filePath, 'utf-8')
// clean up SVG a bit by removing unnecessary whitespace and newline characters
content = content.replace(/\n+/g, '\n').replace(/\s+/g, ' ').trim()
// With xmldom 0.9.5 the parsing is stricter and many
// iconsets have malformed DOCTYPE if done with Illustrator
content = content.replace(/<!DOCTYPE[^>]*>/g, '')
return extractSvg(content, name, options)
}
/**
* Writes the exports to the specified folder.
*
* @param {string} iconSetName - The name of the icon set.
* @param {string} distFolder - The folder to write the exports to.
* @param {string[]} svgExports - The array of SVG exports.
* @param {string[]} typeExports - The array of type exports.
* @param {string[]} skipped - The array of skipped icons.
*/
export const writeExports = async (iconSetName, distFolder, svgExports, typeExports, skipped) => {
if (svgExports.length === 0) {
console.log(`WARNING. ${iconSetName} skipped completely`)
} else {
const distIndex = `${distFolder}/index`
const content = svgExports.sort().join('\n')
writeFileSync(`${distIndex}.js`, content.replace(cjsReplaceRE, 'module.exports.'), 'utf-8')
writeFileSync(`${distIndex}.mjs`, content, 'utf-8')
writeFileSync(`${distIndex}.d.ts`, typeExports.sort().join('\n'), 'utf-8')
if (skipped.length > 0) {
console.log(`${iconSetName} - skipped (${skipped.length}): ${skipped}`)
}
}
}