Browse Source

Replace webfont stroke-to-outline pipeline with unstroke (#1602)

pull/1595/head
Paweł Kuna 3 weeks ago
committed by GitHub
parent
commit
f73808511b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      package.json
  2. 20
      packages/icons-webfont/.build/build-webfont.mjs
  3. 241
      packages/icons-webfont/.build/utilities.mjs
  4. 10
      packages/icons-webfont/package.json
  5. 571
      pnpm-lock.yaml

3
package.json

@ -121,9 +121,6 @@
"sharp",
"svelte-preprocess",
"ttf2woff2"
],
"onlyBuiltDependencies": [
"canvas"
]
}
}

20
packages/icons-webfont/.build/build-webfont.mjs

@ -1,6 +1,7 @@
import path from 'node:path';
import { outlineSvg } from 'unstroke';
import { getAllIcons, getPackageDir, strokes } from '../../../.build/helpers.mjs';
import { generateFont, mergePaths, offsetPath, processIcons, removeComments, reorientPath, splitPaths } from './utilities.mjs';
import { generateFont, processIcons, removeComments } from './utilities.mjs';
const DIR = getPackageDir('icons-webfont')
@ -10,21 +11,18 @@ const filledFiles = getAllIcons(true).filled;
// Generate outline icons
for await (const [strokeName, strokeWidth] of Object.entries(strokes)) {
const dirname = path.join(DIR, 'icons-outlined', strokeName);
await processIcons(
outlineFiles,
dirname,
'outline',
DIR,
strokeName,
(svgContent) => {
svgContent = removeComments(svgContent);
svgContent = splitPaths(svgContent);
svgContent = offsetPath(svgContent, strokeWidth);
svgContent = reorientPath(svgContent);
svgContent = mergePaths(svgContent);
return svgContent;
}
(svgContent) => outlineSvg(removeComments(svgContent), {
strokeWidth,
fill: 'black',
onWarning: (warning) => console.warn(`[${strokeName}] ${warning.message}`),
})
);
await generateFont(strokeName, 'outline', DIR);
@ -33,4 +31,4 @@ for await (const [strokeName, strokeWidth] of Object.entries(strokes)) {
// Generate filled icons
const filledDirname = path.join(DIR, 'icons-filled');
await processIcons(filledFiles, filledDirname, 'filled', DIR);
await generateFont('filled', 'filled', DIR);
await generateFont('filled', 'filled', DIR);

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

@ -2,16 +2,7 @@ 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";
@ -35,10 +26,6 @@ function template(templateString) {
};
}
// Setup paper.js with @napi-rs/canvas
const canvas = createCanvas(1, 1);
paper.setup(canvas);
/**
* @typedef {stream.Readable & { metadata?: { unicode: string[], name: string } }} Svgicons2svgfontStream
*/
@ -110,42 +97,6 @@ export async function buildSvgFont(svgStreams) {
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 (<!-- ... -->)
@ -157,114 +108,6 @@ export function removeComments(svgBuffer) {
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");
}
@ -394,87 +237,3 @@ export async function processIcons(files, dirname, type, DIR, strokeName = null,
return { processed, cached };
}
function applyTransformToPath(pathData, transform) {
try {
const path = new SVGPathCommander(pathData);
// Parse transform string into DOMMatrix-compatible format
// SVGPathCommander.transform() accepts transform object or string
const transformed = path.transform(transform);
return transformed.toString();
} catch (e) {
console.warn('Transform detected but not applied:', e.message, '- Using original path data.');
return pathData;
}
}
export function mergePaths(svgBuffer) {
const pathRegex = /<path\s+([^>]*?)\s*\/?>/gs;
const matches = [...svgBuffer.matchAll(pathRegex)];
if (matches.length <= 1) {
return svgBuffer;
}
const pathDataArray = [];
const fills = new Set();
const strokes = new Set();
const fillRules = new Set();
const warnings = [];
for (const match of matches) {
const attrs = match[1];
const d = attrs.match(/d="([^"]*)"/)?.[1];
const transform = attrs.match(/transform="([^"]*)"/)?.[1];
const fill = attrs.match(/fill="([^"]*)"/)?.[1] || 'none';
const stroke = attrs.match(/stroke="([^"]*)"/)?.[1] || 'none';
const fillRule = attrs.match(/fill-rule="([^"]*)"/)?.[1];
if (fill !== 'none') fills.add(fill);
if (stroke !== 'none') strokes.add(stroke);
if (fillRule) fillRules.add(fillRule);
if (d) {
pathDataArray.push(transform ? applyTransformToPath(d, transform) : d);
}
}
if (fills.size > 1) {
warnings.push(
`Multiple fill colors detected (${fills.size} different colors). All paths will use the first color found.`
);
}
if (strokes.size > 1) {
warnings.push(
`Multiple stroke colors detected (${strokes.size} different colors). All paths will use the first stroke found.`
);
}
if (warnings.length > 0) {
console.warn('mergePaths:', warnings.join(' '));
}
const mergedPathData = pathDataArray.join(' ');
const firstAttrs = matches[0][1];
const fill = firstAttrs.match(/fill="([^"]*)"/)?.[1];
const stroke = firstAttrs.match(/stroke="([^"]*)"/)?.[1];
const strokeWidth = firstAttrs.match(/stroke-width="([^"]*)"/)?.[1];
const fillRule = fillRules.size > 0 ? [...fillRules][0] : null;
const newPathAttrs = ['d="' + mergedPathData + '"'];
if (fill) newPathAttrs.push(`fill="${fill}"`);
if (stroke) newPathAttrs.push(`stroke="${stroke}"`);
if (strokeWidth) newPathAttrs.push(`stroke-width="${strokeWidth}"`);
if (fillRule) newPathAttrs.push(`fill-rule="${fillRule}"`);
const newPath = '<path ' + newPathAttrs.join(' ') + ' />';
const firstMatchStart = matches[0].index;
const lastMatch = matches[matches.length - 1];
const lastMatchEnd = lastMatch.index + lastMatch[0].length;
const before = svgBuffer.substring(0, firstMatchStart);
const after = svgBuffer.substring(lastMatchEnd);
return before + newPath + after;
}

10
packages/icons-webfont/package.json

@ -47,21 +47,15 @@
],
"dependencies": {
"@tabler/icons": "workspace:",
"svg-path-commander": "^2.1.11",
"svgtofont": "^6.5.0"
},
"devDependencies": {
"@napi-rs/canvas": "^0.1.88",
"canvas": "^3.2.0",
"eta": "^3.4.0",
"glob": "^13.0.0",
"paper-jsdom": "^0.12.18",
"svg-path-outline": "^1.0.1",
"svg2ttf": "^6.0.3",
"svgicons2svgfont": "^15.0.1",
"ttf2woff": "^3.0.0",
"unstroke": "1.0.0",
"wawoff2": "^2.0.1"
},
"peerDependencies": {},
"optionalDependencies": {}
}
}

571
pnpm-lock.yaml

File diff suppressed because it is too large
Loading…
Cancel
Save