1 |
|
2 |
|
3 |
|
4 | 'use strict';
|
5 |
|
6 | const { ucs2 } = require('punycode');
|
7 | const { Transform } = require('stream');
|
8 | const Sax = require('sax');
|
9 | const { SVGPathData } = require('svg-pathdata');
|
10 | const svgShapesToPath = require('./svgshapes2svgpath');
|
11 | const { Matrix } = require('transformation-matrix-js');
|
12 |
|
13 | require('string.prototype.codepointat');
|
14 |
|
15 |
|
16 | function parseTransforms(value) {
|
17 | return value
|
18 | .match(/(rotate|translate|scale|skewX|skewY|matrix)\s*\(([^)]*)\)\s*/g)
|
19 | .map(transform => transform.match(/[\w.-]+/g));
|
20 | }
|
21 | function matrixFromTransformAttribute(transformAttributeString) {
|
22 | const transformations = {
|
23 | matrix: (result, ...args) => result.transform(...args),
|
24 | translate: (result, x, y = 0) => result.translate(x, y),
|
25 | scale: (result, x, y = x) => result.scale(x, y),
|
26 | rotate: (result, a, x = 0, y = 0) => {
|
27 | if (0 === x && 0 === y) {
|
28 | result.rotateDeg(a);
|
29 | } else {
|
30 | result
|
31 | .translate(x, y)
|
32 | .rotateDeg(a)
|
33 | .translate(-x, -y);
|
34 | }
|
35 | },
|
36 | skewX: (result, a) => result.skewX(a * Math.PI / 180),
|
37 | skewY: (result, a) => result.skewY(a * Math.PI / 180),
|
38 | };
|
39 |
|
40 | const result = new Matrix();
|
41 | for (const transform of parseTransforms(transformAttributeString)) {
|
42 | transformations[transform[0]](
|
43 | result,
|
44 | ...transform.slice(1).map(parseFloat)
|
45 | );
|
46 | }
|
47 | return result;
|
48 | }
|
49 |
|
50 |
|
51 | function tagShouldRender(curTag, parents) {
|
52 | let values;
|
53 |
|
54 | return !parents.some(tag => {
|
55 | if (
|
56 | 'undefined' !== typeof tag.attributes.display &&
|
57 | 'none' === tag.attributes.display.toLowerCase()
|
58 | ) {
|
59 | return true;
|
60 | }
|
61 | if (
|
62 | 'undefined' !== typeof tag.attributes.width &&
|
63 | 0 === parseFloat(tag.attributes.width)
|
64 | ) {
|
65 | return true;
|
66 | }
|
67 | if (
|
68 | 'undefined' !== typeof tag.attributes.height &&
|
69 | 0 === parseFloat(tag.attributes.height)
|
70 | ) {
|
71 | return true;
|
72 | }
|
73 | if ('undefined' !== typeof tag.attributes.viewBox) {
|
74 | values = tag.attributes.viewBox.split(/\s*,*\s|\s,*\s*|,/);
|
75 | if (0 === parseFloat(values[2]) || 0 === parseFloat(values[3])) {
|
76 | return true;
|
77 | }
|
78 | }
|
79 | return false;
|
80 | });
|
81 | }
|
82 |
|
83 |
|
84 |
|
85 |
|
86 | function getTagColor(currTag, parents) {
|
87 | const defaultColor = 'black';
|
88 | const fillVal = currTag.attributes.fill;
|
89 | let color;
|
90 | const parentsLength = parents.length;
|
91 |
|
92 | if ('none' === fillVal) {
|
93 | return color;
|
94 | }
|
95 | if ('currentColor' === fillVal) {
|
96 | return defaultColor;
|
97 | }
|
98 | if ('inherit' === fillVal) {
|
99 | if (0 === parentsLength) {
|
100 | return defaultColor;
|
101 | }
|
102 | return getTagColor(
|
103 | parents[parentsLength - 1],
|
104 | parents.slice(0, parentsLength - 1)
|
105 | );
|
106 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 | }
|
112 |
|
113 | return fillVal;
|
114 | }
|
115 |
|
116 | class SVGIcons2SVGFontStream extends Transform {
|
117 | constructor(options) {
|
118 | super({ objectMode: true });
|
119 |
|
120 |
|
121 | this._writableState.objectMode = true;
|
122 | this._readableState.objectMode = false;
|
123 |
|
124 | this.glyphs = [];
|
125 |
|
126 | this._options = options || {};
|
127 | this._options.fontName = this._options.fontName || 'iconfont';
|
128 | this._options.fontId = this._options.fontId || this._options.fontName;
|
129 | this._options.fixedWidth = this._options.fixedWidth || false;
|
130 | this._options.descent = this._options.descent || 0;
|
131 | this._options.round = this._options.round || 10e12;
|
132 | this._options.metadata = this._options.metadata || '';
|
133 |
|
134 | this.log = this._options.log || console.log.bind(console);
|
135 | }
|
136 |
|
137 | _transform(svgIconStream, _unused, svgIconStreamCallback) {
|
138 |
|
139 | const saxStream = Sax.createStream(true);
|
140 | const parents = [];
|
141 | const transformStack = [new Matrix()];
|
142 | function applyTransform(d) {
|
143 | return new SVGPathData(d).matrix(
|
144 | ...transformStack[transformStack.length - 1].toArray()
|
145 | );
|
146 | }
|
147 | const glyph = svgIconStream.metadata || {};
|
148 |
|
149 |
|
150 | glyph.width = 0;
|
151 | glyph.height = 1;
|
152 |
|
153 | glyph.paths = [];
|
154 | this.glyphs.push(glyph);
|
155 |
|
156 | if ('string' !== typeof glyph.name) {
|
157 | this.emit(
|
158 | 'error',
|
159 | new Error(
|
160 | `Please provide a name for the glyph at index ${this.glyphs.length -
|
161 | 1}`
|
162 | )
|
163 | );
|
164 | }
|
165 | if (
|
166 | this.glyphs.some(
|
167 | anotherGlyph =>
|
168 | anotherGlyph !== glyph && anotherGlyph.name === glyph.name
|
169 | )
|
170 | ) {
|
171 | this.emit(
|
172 | 'error',
|
173 | new Error(`The glyph name "${glyph.name}" must be unique.`)
|
174 | );
|
175 | }
|
176 | if (
|
177 | glyph.unicode &&
|
178 | glyph.unicode instanceof Array &&
|
179 | glyph.unicode.length
|
180 | ) {
|
181 | if (
|
182 | glyph.unicode.some((unicodeA, i) =>
|
183 | glyph.unicode.some((unicodeB, j) => i !== j && unicodeA === unicodeB)
|
184 | )
|
185 | ) {
|
186 | this.emit(
|
187 | 'error',
|
188 | new Error(
|
189 | `Given codepoints for the glyph "${glyph.name}" contain duplicates.`
|
190 | )
|
191 | );
|
192 | }
|
193 | } else if ('string' !== typeof glyph.unicode) {
|
194 | this.emit(
|
195 | 'error',
|
196 | new Error(`Please provide a codepoint for the glyph "${glyph.name}"`)
|
197 | );
|
198 | }
|
199 |
|
200 | if (
|
201 | this.glyphs.some(
|
202 | anotherGlyph =>
|
203 | anotherGlyph !== glyph && anotherGlyph.unicode === glyph.unicode
|
204 | )
|
205 | ) {
|
206 | this.emit(
|
207 | 'error',
|
208 | new Error(
|
209 | `The glyph "${
|
210 | glyph.name
|
211 | }" codepoint seems to be used already elsewhere.`
|
212 | )
|
213 | );
|
214 | }
|
215 |
|
216 | saxStream.on('opentag', tag => {
|
217 | let values;
|
218 | let color;
|
219 |
|
220 | parents.push(tag);
|
221 | try {
|
222 | const currentTransform = transformStack[transformStack.length - 1];
|
223 |
|
224 | if ('undefined' !== typeof tag.attributes.transform) {
|
225 | const transform = matrixFromTransformAttribute(
|
226 | tag.attributes.transform
|
227 | );
|
228 | transformStack.push(currentTransform.clone().multiply(transform));
|
229 | } else {
|
230 | transformStack.push(currentTransform);
|
231 | }
|
232 |
|
233 | if (!tagShouldRender(tag, parents)) {
|
234 | return;
|
235 | }
|
236 |
|
237 |
|
238 | if ('svg' === tag.name) {
|
239 | if ('viewBox' in tag.attributes) {
|
240 | values = tag.attributes.viewBox.split(/\s*,*\s|\s,*\s*|,/);
|
241 | const dX = parseFloat(values[0]);
|
242 | const dY = parseFloat(values[1]);
|
243 | const width = parseFloat(values[2]);
|
244 | const height = parseFloat(values[3]);
|
245 |
|
246 |
|
247 | glyph.width =
|
248 | 'width' in tag.attributes
|
249 | ? parseFloat(tag.attributes.width)
|
250 | : width;
|
251 | glyph.height =
|
252 | 'height' in tag.attributes
|
253 | ? parseFloat(tag.attributes.height)
|
254 | : height;
|
255 |
|
256 | transformStack[transformStack.length - 1]
|
257 | .translate(-dX, -dY)
|
258 | .scale(glyph.width / width, glyph.height / height);
|
259 | } else {
|
260 | if ('width' in tag.attributes) {
|
261 | glyph.width = parseFloat(tag.attributes.width);
|
262 | } else {
|
263 | this.log(
|
264 | `Glyph "${
|
265 | glyph.name
|
266 | }" has no width attribute, defaulting to 150.`
|
267 | );
|
268 | glyph.width = 150;
|
269 | }
|
270 | if ('height' in tag.attributes) {
|
271 | glyph.height = parseFloat(tag.attributes.height);
|
272 | } else {
|
273 | this.log(
|
274 | `Glyph "${
|
275 | glyph.name
|
276 | }" has no height attribute, defaulting to 150.`
|
277 | );
|
278 | glyph.height = 150;
|
279 | }
|
280 | }
|
281 | } else if ('clipPath' === tag.name) {
|
282 |
|
283 | this.log(
|
284 | `Found a clipPath element in the icon "${
|
285 | glyph.name
|
286 | }" the result may be different than expected.`
|
287 | );
|
288 | } else if ('rect' === tag.name && 'none' !== tag.attributes.fill) {
|
289 | glyph.paths.push(
|
290 | applyTransform(svgShapesToPath.rectToPath(tag.attributes))
|
291 | );
|
292 | } else if ('line' === tag.name && 'none' !== tag.attributes.fill) {
|
293 | this.log(
|
294 | `Found a line element in the icon "${
|
295 | glyph.name
|
296 | }" the result could be different than expected.`
|
297 | );
|
298 | glyph.paths.push(
|
299 | applyTransform(svgShapesToPath.lineToPath(tag.attributes))
|
300 | );
|
301 | } else if ('polyline' === tag.name && 'none' !== tag.attributes.fill) {
|
302 | this.log(
|
303 | `Found a polyline element in the icon "${
|
304 | glyph.name
|
305 | }" the result could be different than expected.`
|
306 | );
|
307 | glyph.paths.push(
|
308 | applyTransform(svgShapesToPath.polylineToPath(tag.attributes))
|
309 | );
|
310 | } else if ('polygon' === tag.name && 'none' !== tag.attributes.fill) {
|
311 | glyph.paths.push(
|
312 | applyTransform(svgShapesToPath.polygonToPath(tag.attributes))
|
313 | );
|
314 | } else if (
|
315 | ['circle', 'ellipse'].includes(tag.name) &&
|
316 | 'none' !== tag.attributes.fill
|
317 | ) {
|
318 | glyph.paths.push(
|
319 | applyTransform(svgShapesToPath.circleToPath(tag.attributes))
|
320 | );
|
321 | } else if (
|
322 | 'path' === tag.name &&
|
323 | tag.attributes.d &&
|
324 | 'none' !== tag.attributes.fill
|
325 | ) {
|
326 | glyph.paths.push(applyTransform(tag.attributes.d));
|
327 | }
|
328 |
|
329 |
|
330 |
|
331 | if ('none' !== tag.attributes.fill) {
|
332 | color = getTagColor(tag, parents);
|
333 | if ('undefined' !== typeof color) {
|
334 | glyph.color = color;
|
335 | }
|
336 | }
|
337 | } catch (err) {
|
338 | this.emit(
|
339 | 'error',
|
340 | new Error(
|
341 | `Got an error parsing the glyph "${glyph.name}": ${err.message}.`
|
342 | )
|
343 | );
|
344 | }
|
345 | });
|
346 |
|
347 | saxStream.on('error', err => {
|
348 | this.emit('error', err);
|
349 | });
|
350 |
|
351 | saxStream.on('closetag', () => {
|
352 | transformStack.pop();
|
353 | parents.pop();
|
354 | });
|
355 |
|
356 | saxStream.on('end', () => {
|
357 | svgIconStreamCallback();
|
358 | });
|
359 |
|
360 | svgIconStream.pipe(saxStream);
|
361 | }
|
362 |
|
363 | _flush(svgFontFlushCallback) {
|
364 | const maxGlyphHeight = this.glyphs.reduce(
|
365 | (curMax, glyph) => Math.max(curMax, glyph.height),
|
366 | 0
|
367 | );
|
368 | const maxGlyphWidth = this.glyphs.reduce(
|
369 | (curMax, glyph) => Math.max(curMax, glyph.width),
|
370 | 0
|
371 | );
|
372 | const fontHeight = this._options.fontHeight || maxGlyphHeight;
|
373 | let fontWidth = maxGlyphWidth;
|
374 | if (this._options.normalize) {
|
375 | fontWidth = this.glyphs.reduce(
|
376 | (curMax, glyph) =>
|
377 | Math.max(curMax, fontHeight / glyph.height * glyph.width),
|
378 | 0
|
379 | );
|
380 | } else if (this._options.fontHeight) {
|
381 |
|
382 | fontWidth *= fontHeight / maxGlyphHeight;
|
383 | }
|
384 |
|
385 | this._options.ascent =
|
386 | 'undefined' !== typeof this._options.ascent
|
387 | ? this._options.ascent
|
388 | : fontHeight - this._options.descent;
|
389 |
|
390 | if (
|
391 | !this._options.normalize &&
|
392 | fontHeight >
|
393 | (1 < this.glyphs.length
|
394 | ? this.glyphs.reduce(
|
395 | (curMin, glyph) => Math.min(curMin, glyph.height),
|
396 | Infinity
|
397 | )
|
398 | : this.glyphs[0].height)
|
399 | ) {
|
400 | this.log(
|
401 | 'The provided icons do not have the same heights. This could lead' +
|
402 | ' to unexpected results. Using the normalize option may help.'
|
403 | );
|
404 | }
|
405 | if (1000 > fontHeight) {
|
406 | this.log(
|
407 | 'A fontHeight of at least than 1000 is recommended, otherwise ' +
|
408 | 'further steps (rounding in svg2ttf) could lead to ugly results.' +
|
409 | ' Use the fontHeight option to scale icons.'
|
410 | );
|
411 | }
|
412 |
|
413 |
|
414 |
|
415 |
|
416 | this.push(
|
417 | '<?xml version="1.0" standalone="no"?>\n' +
|
418 | '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >\n' +
|
419 | '<svg xmlns="http://www.w3.org/2000/svg">\n' +
|
420 | (this._options.metadata
|
421 | ? '<metadata>' + this._options.metadata + '</metadata>\n'
|
422 | : '') +
|
423 | '<defs>\n' +
|
424 | ' <font id="' +
|
425 | this._options.fontId +
|
426 | '" horiz-adv-x="' +
|
427 | fontWidth +
|
428 | '">\n' +
|
429 | ' <font-face font-family="' +
|
430 | this._options.fontName +
|
431 | '"\n' +
|
432 | ' units-per-em="' +
|
433 | fontHeight +
|
434 | '" ascent="' +
|
435 | this._options.ascent +
|
436 | '"\n' +
|
437 | ' descent="' +
|
438 | this._options.descent +
|
439 | '"' +
|
440 | (this._options.fontWeight
|
441 | ? '\n font-weight="' + this._options.fontWeight + '"'
|
442 | : '') +
|
443 | (this._options.fontStyle
|
444 | ? '\n font-style="' + this._options.fontStyle + '"'
|
445 | : '') +
|
446 | ' />\n' +
|
447 | ' <missing-glyph horiz-adv-x="0" />\n'
|
448 | );
|
449 |
|
450 | this.glyphs.forEach(glyph => {
|
451 | const ratio = this._options.normalize
|
452 | ? fontHeight / glyph.height
|
453 | : fontHeight / maxGlyphHeight;
|
454 | if (!isFinite(ratio)) throw new Error('foo');
|
455 | glyph.width *= ratio;
|
456 | glyph.height *= ratio;
|
457 | const glyphPath = new SVGPathData('');
|
458 |
|
459 | if (this._options.fixedWidth) {
|
460 | glyph.width = fontWidth;
|
461 | }
|
462 | const yOffset = glyph.height - this._options.descent;
|
463 | const glyphPathTransform = new Matrix().transform(
|
464 | 1,
|
465 | 0,
|
466 | 0,
|
467 | -1,
|
468 | 0,
|
469 | yOffset
|
470 | );
|
471 | if (1 !== ratio) {
|
472 | glyphPathTransform.scale(ratio, ratio);
|
473 | }
|
474 | glyph.paths.forEach(path => {
|
475 | glyphPath.commands.push(
|
476 | ...path.toAbs().matrix(...glyphPathTransform.toArray()).commands
|
477 | );
|
478 | });
|
479 | if (this._options.centerHorizontally) {
|
480 | const bounds = glyphPath.getBounds();
|
481 |
|
482 | glyphPath.translate(
|
483 | (glyph.width - (bounds.maxX - bounds.minX)) / 2 - bounds.minX
|
484 | );
|
485 | }
|
486 | delete glyph.paths;
|
487 | glyph.unicode.forEach((unicode, i) => {
|
488 | const unicodeStr = ucs2
|
489 | .decode(unicode)
|
490 | .map(point => '&#x' + point.toString(16).toUpperCase() + ';')
|
491 | .join('');
|
492 | const d = glyphPath.round(this._options.round).encode();
|
493 |
|
494 | this.push(
|
495 | ' <glyph glyph-name="' +
|
496 | glyph.name +
|
497 | (0 === i ? '' : '-' + i) +
|
498 | '"\n' +
|
499 | ' unicode="' +
|
500 | unicodeStr +
|
501 | '"\n' +
|
502 | ' horiz-adv-x="' +
|
503 | glyph.width +
|
504 | '" d="' +
|
505 | d +
|
506 | '" />\n'
|
507 | );
|
508 | });
|
509 | });
|
510 | this.push(' </font>\n' + '</defs>\n' + '</svg>\n');
|
511 | this.log('Font created');
|
512 | if ('function' === typeof this._options.callback) {
|
513 | this._options.callback(this.glyphs);
|
514 | }
|
515 | svgFontFlushCallback();
|
516 | }
|
517 | }
|
518 |
|
519 | module.exports = SVGIcons2SVGFontStream;
|