UNPKG

svgicons2svgfont

Version:

Read a set of SVG icons and output a SVG font

581 lines (534 loc) 17.2 kB
import { Transform } from 'stream'; import Sax from 'sax'; import { SVGPathData } from 'svg-pathdata'; import svgShapesToPath from './svgshapes2svgpath.js'; import { type Matrix, scale, translate, compose, fromDefinition, fromTransformAttribute, } from 'transformation-matrix'; import { YError } from 'yerror'; import debug from 'debug'; const warn = debug('svgicons2svgfont'); export { fileSorter } from './filesorter.js'; export * from './iconsdir.js'; export * from './metadata.js'; function matrixFromTransformAttribute(transformAttributeString): Matrix { return compose( fromDefinition(fromTransformAttribute(transformAttributeString)), ); } // Rendering function tagShouldRender(curTag, parents) { let values; return !parents.some((tag) => { if ( 'undefined' !== typeof tag.attributes.display && 'none' === tag.attributes.display.toLowerCase() ) { return true; } if ( 'undefined' !== typeof tag.attributes.width && 0 === parseFloat(tag.attributes.width) ) { return true; } if ( 'undefined' !== typeof tag.attributes.height && 0 === parseFloat(tag.attributes.height) ) { return true; } if ('undefined' !== typeof tag.attributes.viewBox) { values = tag.attributes.viewBox.split(/\s*,*\s|\s,*\s*|,/); if (0 === parseFloat(values[2]) || 0 === parseFloat(values[3])) { return true; } } return false; }); } // According to the document (http://www.w3.org/TR/SVG/painting.html#FillProperties) // fill <paint> none|currentColor|inherit|<color> // [<icccolor>]|<funciri> (not support yet) function getTagColor(currTag, parents) { const defaultColor = 'black'; const fillVal = currTag.attributes.fill; let color; const parentsLength = parents.length; if ('none' === fillVal) { return color; } if ('currentColor' === fillVal) { return defaultColor; } if ('inherit' === fillVal) { if (0 === parentsLength) { return defaultColor; } return getTagColor( parents[parentsLength - 1], parents.slice(0, parentsLength - 1), ); // this might be null. // For example: <svg ><path fill="inherit" /> </svg> // in this case getTagColor should return null // recursive call, the bottom element should be svg, // and svg didn't fill color, so just return null } return fillVal; } export type SVGIcons2SVGFontStreamOptions = { fontName: string; fontId: string; fixedWidth: boolean; descent: number; ascent?: number; round: number; metadata: string; usePathBounds: boolean; normalize?: boolean; preserveAspectRatio?: boolean; centerHorizontally?: boolean; centerVertically?: boolean; fontWeight?: number; fontHeight?: number; fontStyle?: string; callback?: (glyphs: Glyph[]) => void; }; export type Glyph = { name: string; width: number; height: number; defaultHeight?: number; defaultWidth?: number; unicode: string[]; paths?: SVGPathData[]; }; export class SVGIcons2SVGFontStream extends Transform { private _options: SVGIcons2SVGFontStreamOptions; glyphs: Glyph[]; constructor(options: Partial<SVGIcons2SVGFontStreamOptions>) { super({ objectMode: true }); this.glyphs = []; this._options = { ...options, fontName: options.fontName || 'iconfont', fontId: options.fontId || options.fontName || 'iconfont', fixedWidth: options.fixedWidth || false, descent: options.descent || 0, round: options.round || 10e12, metadata: options.metadata || '', usePathBounds: options.usePathBounds || false, }; } _transform(svgIconStream, _unused, svgIconStreamCallback) { // Parsing each icons asynchronously const saxStream = Sax.createStream(true); const parents: (Sax.Tag | Sax.QualifiedTag)[] = []; const transformStack: Matrix[] = []; function applyTransform(d) { const last = transformStack[transformStack.length - 1]; if (!last) return new SVGPathData(d); return new SVGPathData(d).matrix( last.a, last.b, last.c, last.d, last.e, last.f, ); } const glyph = svgIconStream.metadata || {}; // init width and height os they aren't undefined if <svg> isn't renderable glyph.width = 0; glyph.height = 1; glyph.paths = []; this.glyphs.push(glyph); if ('string' !== typeof glyph.name) { this.emit( 'error', new Error( `Please provide a name for the glyph at index ${ this.glyphs.length - 1 }`, ), ); } if ( this.glyphs.some( (anotherGlyph) => anotherGlyph !== glyph && anotherGlyph.name === glyph.name, ) ) { this.emit( 'error', new Error(`The glyph name "${glyph.name}" must be unique.`), ); } if ( glyph.unicode && glyph.unicode instanceof Array && glyph.unicode.length ) { if ( glyph.unicode.some((unicodeA, i) => glyph.unicode.some((unicodeB, j) => i !== j && unicodeA === unicodeB), ) ) { this.emit( 'error', new Error( `Given codepoints for the glyph "${glyph.name}" contain duplicates.`, ), ); } } else if ('string' !== typeof glyph.unicode) { this.emit( 'error', new Error(`Please provide a codepoint for the glyph "${glyph.name}"`), ); } if ( this.glyphs.some( (anotherGlyph) => anotherGlyph !== glyph && anotherGlyph.unicode === glyph.unicode, ) ) { this.emit( 'error', new Error( `The glyph "${glyph.name}" codepoint seems to be used already elsewhere.`, ), ); } saxStream.on('opentag', (tag) => { let values; let color; parents.push(tag); try { const currentTransform = transformStack[transformStack.length - 1]; if ('undefined' !== typeof tag.attributes.transform) { const transform = matrixFromTransformAttribute( tag.attributes.transform, ); transformStack.push( compose([currentTransform, transform].filter(Boolean)), ); } else { transformStack.push(currentTransform); } // Checking if any parent rendering is disabled and exit if so if (!tagShouldRender(tag, parents)) { return; } // Save the view size if ('svg' === tag.name) { if ('viewBox' in tag.attributes) { values = (tag.attributes.viewBox as string).split( /\s*,*\s|\s,*\s*|,/, ); const dX = parseFloat(values[0]); const dY = parseFloat(values[1]); const width = parseFloat(values[2]); const height = parseFloat(values[3]); // use the viewBox width/height if not specified explictly glyph.width = 'width' in tag.attributes ? parseFloat(tag.attributes.width as string) : width; glyph.height = 'height' in tag.attributes ? parseFloat(tag.attributes.height as string) : height; transformStack[transformStack.length - 1] = compose( [ transformStack[transformStack.length - 1], translate(-dX, -dY), scale(glyph.width / width, glyph.height / height), ].filter(Boolean), ); } else { if ('width' in tag.attributes) { glyph.width = parseFloat(tag.attributes.width as string); } else { warn( `⚠️ - Glyph "${glyph.name}" has no width attribute, using current glyph horizontal bounds.`, ); glyph.defaultWidth = true; } if ('height' in tag.attributes) { glyph.height = parseFloat(tag.attributes.height as string); } else { warn( `⚠️ - Glyph "${glyph.name}" has no height attribute, using current glyph vertical bounds.`, ); glyph.defaultHeight = true; } } } else if ('clipPath' === tag.name) { // Clipping path unsupported warn( `🤷 - Found a clipPath element in the icon "${glyph.name}" the result may be different than expected.`, ); } else if ('rect' === tag.name && 'none' !== tag.attributes.fill) { glyph.paths.push( applyTransform(svgShapesToPath.rectToPath(tag.attributes)), ); } else if ('line' === tag.name && 'none' !== tag.attributes.fill) { warn( `🤷 - Found a line element in the icon "${glyph.name}" the result could be different than expected.`, ); glyph.paths.push( applyTransform(svgShapesToPath.lineToPath(tag.attributes)), ); } else if ('polyline' === tag.name && 'none' !== tag.attributes.fill) { warn( `🤷 - Found a polyline element in the icon "${glyph.name}" the result could be different than expected.`, ); glyph.paths.push( applyTransform(svgShapesToPath.polylineToPath(tag.attributes)), ); } else if ('polygon' === tag.name && 'none' !== tag.attributes.fill) { glyph.paths.push( applyTransform(svgShapesToPath.polygonToPath(tag.attributes)), ); } else if ( ['circle', 'ellipse'].includes(tag.name) && 'none' !== tag.attributes.fill ) { glyph.paths.push( applyTransform(svgShapesToPath.circleToPath(tag.attributes)), ); } else if ( 'path' === tag.name && tag.attributes.d && 'none' !== tag.attributes.fill ) { glyph.paths.push(applyTransform(tag.attributes.d)); } // According to http://www.w3.org/TR/SVG/painting.html#SpecifyingPaint // Map attribute fill to color property if ('none' !== tag.attributes.fill) { color = getTagColor(tag, parents); if ('undefined' !== typeof color) { glyph.color = color; } } } catch (err) { this.emit( 'error', new Error( `Got an error parsing the glyph "${glyph.name}": ${(err as Error)?.message}.`, ), ); } }); saxStream.on('error', (err) => { this.emit('error', err); }); saxStream.on('closetag', () => { transformStack.pop(); parents.pop(); }); saxStream.on('end', () => { svgIconStreamCallback(); }); svgIconStream.pipe(saxStream); } _flush(svgFontFlushCallback) { this.glyphs.forEach((glyph) => { if ( glyph.defaultHeight || glyph.defaultWidth || this._options.usePathBounds ) { const glyphPath = new SVGPathData(''); (glyph.paths || []).forEach((path) => { glyphPath.commands.push(...path.commands); }); const bounds = glyphPath.getBounds(); if (glyph.defaultHeight || this._options.usePathBounds) { glyph.height = bounds.maxY - bounds.minY; } if (glyph.defaultWidth || this._options.usePathBounds) { glyph.width = bounds.maxX - bounds.minX; } } }); const maxGlyphHeight = this.glyphs.reduce( (curMax, glyph) => Math.max(curMax, glyph.height), 0, ); const maxGlyphWidth = this.glyphs.reduce( (curMax, glyph) => Math.max(curMax, glyph.width), 0, ); const fontHeight = this._options.fontHeight || maxGlyphHeight; let fontWidth = maxGlyphWidth; if (this._options.normalize) { fontWidth = this.glyphs.reduce( (curMax, glyph) => Math.max(curMax, (fontHeight / glyph.height) * glyph.width), 0, ); } else if (this._options.fontHeight) { // even if normalize is off, we need to scale the fontWidth if we have a custom fontHeight fontWidth *= fontHeight / maxGlyphHeight; } this._options.ascent = 'undefined' !== typeof this._options.ascent ? this._options.ascent : fontHeight - this._options.descent; if ( !this._options.normalize && fontHeight > (1 < this.glyphs.length ? this.glyphs.reduce( (curMin, glyph) => Math.min(curMin, glyph.height), Infinity, ) : this.glyphs[0].height) ) { warn( '🤷 - The provided icons do not have the same heights. This could lead' + ' to unexpected results. Using the normalize option may help.', ); } if (1000 > fontHeight) { warn( '🤷 - A fontHeight of at least than 1000 is recommended, otherwise ' + 'further steps (rounding in svg2ttf) could lead to ugly results.' + ' Use the fontHeight option to scale icons.', ); } // Output the SVG file // (find a SAX parser that allows modifying SVG on the fly) this.push( '<?xml version="1.0" standalone="no"?>\n' + '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >\n' + '<svg xmlns="http://www.w3.org/2000/svg">\n' + (this._options.metadata ? '<metadata>' + this._options.metadata + '</metadata>\n' : '') + '<defs>\n' + ' <font id="' + this._options.fontId + '" horiz-adv-x="' + fontWidth + '">\n' + ' <font-face font-family="' + this._options.fontName + '"\n' + ' units-per-em="' + fontHeight + '" ascent="' + this._options.ascent + '"\n' + ' descent="' + this._options.descent + '"' + (this._options.fontWeight ? '\n font-weight="' + this._options.fontWeight + '"' : '') + (this._options.fontStyle ? '\n font-style="' + this._options.fontStyle + '"' : '') + ' />\n' + ' <missing-glyph horiz-adv-x="0" />\n', ); this.glyphs.forEach((glyph) => { const ratio = this._options.normalize ? fontHeight / (this._options.preserveAspectRatio && glyph.width > glyph.height ? glyph.width : glyph.height) : fontHeight / maxGlyphHeight; if (!isFinite(ratio)) { throw new YError('E_BAD_COMPUTED_RATIO', ratio); } glyph.width *= ratio; glyph.height *= ratio; const glyphPath = new SVGPathData(''); if (this._options.fixedWidth) { glyph.width = fontWidth; } const yOffset = glyph.height - this._options.descent; let glyphPathTransform: Matrix = { a: 1, b: 0, c: 0, d: -1, e: 0, f: yOffset, }; // ySymmetry if (1 !== ratio) { glyphPathTransform = compose(glyphPathTransform, scale(ratio, ratio)); } (glyph.paths || []).forEach((path) => { glyphPath.commands.push( ...path .toAbs() .matrix( glyphPathTransform.a, glyphPathTransform.b, glyphPathTransform.c, glyphPathTransform.d, glyphPathTransform.e, glyphPathTransform.f, ).commands, ); }); const bounds = (this._options.centerHorizontally || this._options.centerVertically) && glyphPath.getBounds(); if (this._options.centerHorizontally && bounds && 'maxX' in bounds) { glyphPath.translate( (glyph.width - (bounds.maxX - bounds.minX)) / 2 - bounds.minX, ); } if (this._options.centerVertically && bounds && 'maxX' in bounds) { glyphPath.translate( 0, (fontHeight - (bounds.maxY - bounds.minY)) / 2 - bounds.minY - this._options.descent, ); } delete glyph.paths; const d = glyphPath.round(this._options.round).encode(); glyph.unicode.forEach((unicode, i) => { const unicodeStr = [...unicode] .map( (char) => '&#x' + char.codePointAt(0)!.toString(16).toUpperCase() + ';', ) .join(''); this.push( ' <glyph glyph-name="' + glyph.name + (0 === i ? '' : '-' + i) + '"\n' + ' unicode="' + unicodeStr + '"\n' + ' horiz-adv-x="' + glyph.width + '" d="' + d + '" />\n', ); }); }); this.push(' </font>\n' + '</defs>\n' + '</svg>\n'); warn('✅ - Font created'); if ('function' === typeof this._options.callback) { this._options.callback(this.glyphs); } svgFontFlushCallback(); } }