tabler-icons/packages/icons-webfont/.build/utilities.mjs

397 lines
13 KiB
JavaScript

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<Svgicons2svgfontStream[]>}
*/
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<string>}
*/
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(/<!--[\s\S]*?-->/g, '');
svgBuffer = svgBuffer.replace(blankSquare, '')
return svgBuffer;
}
// Function to split all paths in SVG into individual segments
export function splitPaths(svgBuffer) {
svgBuffer = svgBuffer.replaceAll(/<path([^>]*)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 <path> elements
if (individualPaths.length > 1) {
const newPaths = individualPaths.map(path => {
return `<path${p1}d="${path}"${p3}>`;
});
return newPaths.join('\n');
} else if (individualPaths.length === 1) {
// Even if only one path, return it (might be different from original)
return `<path${p1}d="${individualPaths[0]}"${p3}>`;
} else {
// Fallback to original
return match;
}
});
return svgBuffer;
}
export function reorientPath(svgBuffer) {
let result = svgBuffer;
const pathRegex = /<path.*?d="([^"]+)"/g;
const matches = [...svgBuffer.matchAll(pathRegex)];
for (const match of matches) {
const originalPath = match[1];
const compoundPath = new paper.CompoundPath(originalPath);
const newPath = compoundPath.reorient(false, false).pathData;
result = result.replace(originalPath, newPath);
}
return result;
}
export function offsetPath(svgBuffer, offset) {
svgBuffer = svgBuffer.replaceAll(/<path[^>]*d="([^"]*)"/g, (match, p1) => {
let newPath = spo(new SVGPathCommander(p1).toAbsolute().toString(), offset / 2 - 0.001, {
inside: true,
outside: true,
joints: 0
});
return `<path d="${newPath}"`
})
svgBuffer = svgBuffer.replaceAll(/stroke="[^"]*"/g, 'stroke="none"')
svgBuffer = svgBuffer.replaceAll(/fill="[^"]*"/g, 'fill="black"')
return svgBuffer;
}
export function calculateHash(content) {
return crypto.createHash('sha1').update(content).digest("hex");
}
export async function generateFont(strokeName, type, DIR) {
console.log(`Generating font for ${type === 'outline' ? `outline/${strokeName}` : `filled`}`);
const svgFiles = await loadSvgFiles(path.join(DIR, `icons-${type === 'outline' ? `outlined/${strokeName}` : 'filled'}`));
const svgFontFileSource = await buildSvgFont(svgFiles);
const ttfFile = Buffer.from(svg2ttf(svgFontFileSource).buffer);
const woffFile = Buffer.from(ttf2woff(ttfFile).buffer);
const woff2File = await wawoff2.compress(ttfFile);
const fileName = `tabler-icons${type === 'outline' ? (strokeName !== "400" ? `-${strokeName}` : '') : `-${type}`}`;
// Ensure dist/fonts directory exists
mkdirSync(path.join(DIR, 'dist/fonts'), { recursive: true });
writeFileSync(path.join(DIR, `dist/fonts/${fileName}.svg`), svgFontFileSource); // for debug
writeFileSync(path.join(DIR, `dist/fonts/${fileName}.ttf`), ttfFile);
writeFileSync(path.join(DIR, `dist/fonts/${fileName}.woff`), woffFile);
writeFileSync(path.join(DIR, `dist/fonts/${fileName}.woff2`), woff2File);
const glyphs = svgFiles.map(f => ({
...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(/<!--\!cache:([a-z0-9]+)-->/, (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 = `<!--!cache:${calculateHash(finalContent)}-->`;
// 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 };
}