import fsPromises from 'node:fs/promises'; import { createReadStream, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import path from 'node:path'; import { globSync } from 'glob'; import SVGPathCommander, { parsePathString, pathToString } from 'svg-path-commander'; import { blankSquare, getAliases, getPackageJson } from '../../../.build/helpers.mjs'; import spo from 'svg-path-outline'; // Import canvas before paper-jsdom to ensure it's available for jsdom // Use require for canvas to ensure it's loaded before jsdom initializes import { createRequire } from 'module'; const require = createRequire(import.meta.url); require('canvas'); import paper from "paper-jsdom"; import { createCanvas } from '@napi-rs/canvas'; import crypto from 'crypto'; import { Eta } from 'eta'; import svg2ttf from "svg2ttf"; import ttf2woff from "ttf2woff"; import wawoff2 from "wawoff2"; // Create Eta instance const eta = new Eta({ autoEscape: false }); // Get aliases const aliases = getAliases(true) const packageJson = getPackageJson() // Template function compatible with lodash.template API function template(templateString) { return function(data) { return eta.renderString(templateString, data); }; } // Setup paper.js with @napi-rs/canvas const canvas = createCanvas(1, 1); paper.setup(canvas); /** * @typedef {stream.Readable & { metadata?: { unicode: string[], name: string } }} Svgicons2svgfontStream */ /** * * @param name {string} name * @return {import('svgicons2svgfont').FileMetadata} */ function getMetadataFromSvgName(name) { // we process uUnicode-Name.svg const firstHyphen = name.indexOf('-'); const unicode = name.slice(1, firstHyphen); const iconName = name.slice(firstHyphen + 1, -4); const unicodeChar = String.fromCodePoint(parseInt(unicode, 16)); return { unicode: [unicodeChar], name: iconName, } } /** * * @param path {string} The directory contains SVG files * @return {Promise} */ export async function loadSvgFiles(path) { const svgFiles = await fsPromises.readdir(path).then(files => files.filter(file => file.endsWith('.svg'))); svgFiles.sort(); return svgFiles.map(file => { /** @type {Svgicons2svgfontStream} */ const stream = createReadStream(`${path}/${file}`); stream.metadata = getMetadataFromSvgName(file); return stream; }); } /** * * @param svgStreams {Svgicons2svgfontStream[]} SVG files * @return {Promise} */ export async function buildSvgFont(svgStreams) { const { SVGIcons2SVGFontStream } = await import("svgicons2svgfont"); const fontStream = new SVGIcons2SVGFontStream({ fontName: 'tabler-icons', normalize: true, fontHeight: 1000, descent: 100, ascent: 900, fixedWidth: false, }); const fontStreamPromise = new Promise((resolve, reject) => { const buffers = []; fontStream.on('data', chunk => buffers.push(chunk)); fontStream.on('finish', () => { resolve(buffers.join('')); }); fontStream.on('error', reject); }); svgStreams.forEach(stream => { fontStream.write(stream); }); fontStream.end(); return await fontStreamPromise; } // Function to calculate the end point of a segment export function getEndPoint(segment, startPoint) { const [command, ...params] = segment; const upperCommand = command.toUpperCase(); switch (upperCommand) { case 'M': return [params[0], params[1]]; case 'L': return [params[0], params[1]]; case 'H': return [params[0], startPoint[1]]; case 'V': return [startPoint[0], params[0]]; case 'C': // Cubic Bezier: C x1 y1 x2 y2 x y return [params[4], params[5]]; case 'S': // Smooth Cubic Bezier: S x2 y2 x y return [params[2], params[3]]; case 'Q': // Quadratic Bezier: Q x1 y1 x y return [params[2], params[3]]; case 'T': // Smooth Quadratic Bezier: T x y return [params[0], params[1]]; case 'A': // Arc: A rx ry x-axis-rotation large-arc-flag sweep-flag x y return [params[5], params[6]]; case 'Z': return startPoint; default: return startPoint; } } // Function to remove XML/HTML comments from SVG export function removeComments(svgBuffer) { // Remove all XML/HTML comments () // Using non-greedy match to handle multiline comments svgBuffer = svgBuffer.replace(//g, ''); svgBuffer = svgBuffer.replace(blankSquare, '') return svgBuffer; } // Function to split all paths in SVG into individual segments export function splitPaths(svgBuffer) { svgBuffer = svgBuffer.replaceAll(/]*)d="([^"]*)"([^>]*)>/g, (match, p1, p2, p3) => { // Convert path to absolute format const absolutePath = new SVGPathCommander(p2).toAbsolute().toString(); // Parse path string to PathArray const pathArray = parsePathString(absolutePath) if (!Array.isArray(pathArray) || pathArray.length === 0) { return match; } // Track current position and path start point let currentPoint = [0, 0]; let pathStartPoint = [0, 0]; const individualPaths = []; for (let i = 0; i < pathArray.length; i++) { const segment = pathArray[i]; const [command] = segment; const upperCommand = command.toUpperCase(); if (upperCommand === 'M') { // MoveTo command - update current position and path start currentPoint = [segment[1], segment[2]]; pathStartPoint = currentPoint; // If there's a next segment, create a path from M to that segment if (i + 1 < pathArray.length) { const nextSegment = pathArray[i + 1]; const newPath = pathToString([segment, nextSegment]); individualPaths.push(newPath); currentPoint = getEndPoint(nextSegment, currentPoint); i++; // Skip next segment as we've already processed it } } else if (upperCommand === 'Z') { // Close path - create a line back to start if (individualPaths.length > 0) { // Add Z to the last path if it doesn't already have it let lastPath = individualPaths[individualPaths.length - 1]; if (!lastPath.trim().endsWith('Z') && !lastPath.trim().endsWith('z')) { const lastPathArray = parsePathString(lastPath); lastPathArray.push(['Z']); lastPath = pathToString(lastPathArray); individualPaths[individualPaths.length - 1] = lastPath; } } currentPoint = pathStartPoint; } else { // Any other command - create a new path starting with M const newPath = pathToString([ ['M', currentPoint[0], currentPoint[1]], segment ]); individualPaths.push(newPath); currentPoint = getEndPoint(segment, currentPoint); } } // If we have multiple paths, create separate elements if (individualPaths.length > 1) { const newPaths = individualPaths.map(path => { return ``; }); return newPaths.join('\n'); } else if (individualPaths.length === 1) { // Even if only one path, return it (might be different from original) return ``; } else { // Fallback to original return match; } }); return svgBuffer; } export function reorientPath(svgBuffer) { let result = svgBuffer; const pathRegex = /]*d="([^"]*)"/g, (match, p1) => { let newPath = spo(new SVGPathCommander(p1).toAbsolute().toString(), offset / 2 - 0.001, { inside: true, outside: true, joints: 0 }); return ` ({ ...f.metadata, unicodeHex: f.metadata.unicode && f.metadata.unicode[0] ? f.metadata.unicode[0].codePointAt(0).toString(16) : '' })) .sort(function (a, b) { return a.name.localeCompare(b.name) }) // Convert aliases object to array of {from, to} objects const aliasesArray = aliases[type] ? Object.entries(aliases[type]).map(([from, to]) => ({ from, to })) : [] const options = { name: `Tabler Icons ${type.charAt(0).toUpperCase() + type.slice(1)}`, fileName, glyphs, v: packageJson.version, aliases: aliasesArray } //scss const compiled = template(readFileSync(path.join(DIR, '.build/iconfont.scss')).toString()) const resultSCSS = compiled(options) writeFileSync(path.join(DIR, `dist/${fileName}.scss`), resultSCSS) //html const compiledHtml = template(readFileSync(path.join(DIR, '.build/iconfont.html')).toString()) const resultHtml = compiledHtml(options) writeFileSync(path.join(DIR, `dist/${fileName}.html`), resultHtml) } // Process icons with cache mechanism export async function processIcons(files, dirname, type, DIR, strokeName = null, processContentFn = null) { mkdirSync(dirname, { recursive: true }); let processed = 0; let cached = 0; const startTime = Date.now(); const filesList = new Set(files .filter(({ unicode }) => unicode) .map(({ name, unicode }) => `u${unicode.toUpperCase()}-${name}.svg`) ); for (const file of files) { const { name, content, unicode } = file; if (!unicode) continue; let svgContent = content; const fileName = `u${unicode.toUpperCase()}-${name}`; const filePath = path.join(dirname, `${fileName}.svg`); // Check cache (try/catch faster than existsSync + readFileSync) try { const cachedContent = readFileSync(filePath, 'utf-8'); let cachedHash = ''; const contentWithoutHash = cachedContent.replace(//, (m, hash) => { cachedHash = hash; return ''; }); if (cachedHash && calculateHash(contentWithoutHash) === cachedHash) { cached++; continue; } } catch (e) { // File doesn't exist, will be created } const logPrefix = strokeName ? `${strokeName}/${fileName}` : `${type}/${fileName}`; console.log(`Writing to ${logPrefix}`); // Process content if processing function is provided if (processContentFn) { svgContent = processContentFn(svgContent); } // Prepare final content with hash const finalContent = svgContent.replace(/\n/g, ' ').trim(); const hashString = ``; // Save file writeFileSync(filePath, finalContent + hashString, 'utf-8'); processed++; } // Remove old files const globPattern = strokeName ? path.join(DIR, `icons-outlined/${strokeName}/*.svg`) : path.join(DIR, `icons-filled/*.svg`); const existedFiles = (globSync(globPattern)).map(file => path.basename(file)); existedFiles.forEach(file => { if (!filesList.has(file)) { console.log('Remove:', file); unlinkSync(path.join(dirname, file)); } }); const totalTime = ((Date.now() - startTime) / 1000).toFixed(1); const logPrefix = strokeName ? `[${strokeName}]` : `[${type}]`; console.log(`\n${logPrefix} Done: ${processed} processed, ${cached} cached in ${totalTime}s`); return { processed, cached }; }