From 75a447c73386257135bb9ff95cd387a75dd716df Mon Sep 17 00:00:00 2001 From: codecalm Date: Thu, 18 Dec 2025 20:19:38 +0100 Subject: [PATCH] Refactor SVG outline fixing logic to improve path direction handling and optimize performance. --- .../icons-webfont/.build/build-outline.mjs | 6 +- packages/icons-webfont/.build/fix-outline.mjs | 302 ++++++++++++++---- 2 files changed, 245 insertions(+), 63 deletions(-) diff --git a/packages/icons-webfont/.build/build-outline.mjs b/packages/icons-webfont/.build/build-outline.mjs index 21f42e1cd..88a418dd4 100644 --- a/packages/icons-webfont/.build/build-outline.mjs +++ b/packages/icons-webfont/.build/build-outline.mjs @@ -4,7 +4,6 @@ import fs from 'fs' import { resolve, basename } from 'path' import crypto from 'crypto' import { glob } from 'glob' -import { optimize } from 'svgo' import { fixOutline } from './fix-outline.mjs' import os from 'os' @@ -112,11 +111,8 @@ const buildOutline = async () => { // Fix outline direction (using JS instead of fontforge) const fixed = fixOutline(outlined) - // Optimize with svgo (in memory, no subprocess) - const optimized = optimize(fixed, { multipass: true }).data - // Prepare final content with hash - const finalContent = optimized.replace(/\n/g, ' ').trim() + const finalContent = fixed.replace(/\n/g, ' ').trim() const hashString = `` // Save file diff --git a/packages/icons-webfont/.build/fix-outline.mjs b/packages/icons-webfont/.build/fix-outline.mjs index 1a429b695..4f79e0c96 100644 --- a/packages/icons-webfont/.build/fix-outline.mjs +++ b/packages/icons-webfont/.build/fix-outline.mjs @@ -2,80 +2,267 @@ import fs from 'fs' import SVGPathCommander from 'svg-path-commander' /** - * Fix SVG outline directions - replacement for fontforge fix-outline.py - * Operations: - * 1. Round coordinates - * 2. Correct path direction (outer paths counterclockwise, inner paths clockwise) + * Fix SVG outline directions for font glyphs + * For TrueType fonts in SVG coordinate system (Y down): + * - Outer paths (even depth): clockwise + * - Inner paths/holes (odd depth): counterclockwise */ export function fixOutline(svgContent) { - // Extract all path elements - const pathRegex = /]*\sd="([^"]+)"[^>]*>/g + // Change fill-rule from evenodd to nonzero for correct hole-in-hole handling + svgContent = svgContent.replace(/fill-rule="evenodd"/g, 'fill-rule="nonzero"') - let result = svgContent.replace(pathRegex, (match, pathData) => { + const pathRegex = /]*\sd="([^"]+)"[^>]*>/g + + return svgContent.replace(pathRegex, (match, pathData) => { try { - // Round coordinates to integers (like fontforge's round()) and optimize - const commander = new SVGPathCommander(pathData, { round: 0 }) - const optimized = commander.optimize().toString() - - // Check and correct direction - // For font glyphs: outer paths should be counterclockwise - const segments = new SVGPathCommander(optimized).segments - const isClockwise = getPathDirection(segments) - - let finalPath = optimized - - // If path is clockwise, reverse it to make it counterclockwise (standard for outer contours) - if (isClockwise) { - finalPath = new SVGPathCommander(optimized, { round: 0 }).reverse().toString() + const absolutePathData = new SVGPathCommander(pathData, { round: 0 }).toAbsolute().toString() + const subpaths = absolutePathData.match(/M[^M]*/g) + + if (!subpaths || subpaths.length === 0) return match + + if (subpaths.length === 1) { + const segments = new SVGPathCommander(absolutePathData).segments + const isClockwise = getPathDirection(segments) + const resultPath = isClockwise ? absolutePathData : reversePath(absolutePathData, segments) + return match.replace(pathData, new SVGPathCommander(resultPath, { round: 0 }).optimize().toString()) } - - return match.replace(pathData, finalPath) + + // Analyze all subpaths in one pass + const infos = subpaths.map((sp, idx) => { + const segments = new SVGPathCommander(sp.trim()).segments + const { bbox, isClockwise } = analyzeSegments(segments) + const bboxArea = bbox.w * bbox.h + // Calculate actual area using shoelace formula + const actualArea = calculateArea(segments) + // If area/bboxArea ratio is low (<60%), it's likely a line/shape, not a circle/hole + const isLikelyHole = bboxArea > 0 && (actualArea / bboxArea) > 0.6 + return { idx, path: sp.trim(), segments, bbox, isClockwise, area: bboxArea, actualArea, isLikelyHole } + }) + + // Find direct parent for each subpath (smallest container) + // Always check containment - if contained, it's a hole regardless of ratio + const parents = infos.map((info, i) => { + let parent = null + let parentArea = Infinity + for (let j = 0; j < infos.length; j++) { + if (i !== j && bboxContains(infos[j].bbox, info.bbox)) { + // This subpath contains info, check if it's the smallest + if (infos[j].area < parentArea) { + parent = j + parentArea = infos[j].area + } + } + } + return parent + }) + + // Calculate depth by traversing up the tree + const getDepth = (idx) => { + let depth = 0 + let current = parents[idx] + while (current !== null) { + depth++ + current = parents[current] + } + return depth + } + + // Fix directions: each subpath that is a hole in its direct parent should be CCW + // If contained by another subpath, it's always a hole (CCW), regardless of ratio + // This works correctly with nonzero fill-rule: holes are always CCW relative to their container + const corrected = infos.map((info, i) => { + const parent = parents[i] + // If no parent, it's a root shape -> should be CW + // If has parent, it's a hole in that parent -> should be CCW + const shouldBeClockwise = parent === null + + return info.isClockwise === shouldBeClockwise + ? info.path + : reversePath(info.path, info.segments) + }) + + return match.replace(pathData, corrected.join(' ')) } catch (e) { - console.warn('Could not process path:', e.message) return match } }) - - return result } /** - * Calculate path direction using shoelace formula - * Returns true if clockwise, false if counterclockwise + * Calculate actual area using shoelace formula */ -function getPathDirection(segments) { +function calculateArea(segments) { let sum = 0 - let points = [] - - // Extract points from segments - for (const seg of segments) { - if (seg[0] === 'M' || seg[0] === 'L') { - points.push({ x: seg[1], y: seg[2] }) - } else if (seg[0] === 'C') { - // For curves, use the endpoint - points.push({ x: seg[5], y: seg[6] }) - } else if (seg[0] === 'Q') { - points.push({ x: seg[3], y: seg[4] }) - } else if (seg[0] === 'Z' || seg[0] === 'z') { - // Close path - use first point - if (points.length > 0) { - points.push(points[0]) + let prevX = 0, prevY = 0, firstX = 0, firstY = 0 + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + const cmd = seg[0] + let x, y + + if (cmd === 'M') { + x = seg[1]; y = seg[2] + firstX = x; firstY = y + } else if (cmd === 'L') { + x = seg[1]; y = seg[2] + } else if (cmd === 'H') { + x = seg[1]; y = prevY + } else if (cmd === 'V') { + x = prevX; y = seg[1] + } else if (cmd === 'C') { + x = seg[5]; y = seg[6] + } else if (cmd === 'Q') { + x = seg[3]; y = seg[4] + } else if (cmd === 'Z' || cmd === 'z') { + x = firstX; y = firstY + } else { + continue + } + + if (i > 0) sum += (x - prevX) * (y + prevY) + prevX = x; prevY = y + } + + return Math.abs(sum / 2) +} + +/** + * Analyze segments - get bbox and direction in single pass + */ +function analyzeSegments(segments) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + let sum = 0, prevX = 0, prevY = 0, firstX = 0, firstY = 0 + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + const cmd = seg[0] + let x, y + + if (cmd === 'M') { + x = seg[1]; y = seg[2] + firstX = x; firstY = y + } else if (cmd === 'L') { + x = seg[1]; y = seg[2] + } else if (cmd === 'H') { + x = seg[1]; y = prevY + } else if (cmd === 'V') { + x = prevX; y = seg[1] + } else if (cmd === 'C') { + // Update bbox with control points too + for (let j = 1; j <= 5; j += 2) { + if (seg[j] < minX) minX = seg[j] + if (seg[j] > maxX) maxX = seg[j] } + for (let j = 2; j <= 6; j += 2) { + if (seg[j] < minY) minY = seg[j] + if (seg[j] > maxY) maxY = seg[j] + } + x = seg[5]; y = seg[6] + } else if (cmd === 'Q') { + if (seg[1] < minX) minX = seg[1] + if (seg[1] > maxX) maxX = seg[1] + if (seg[2] < minY) minY = seg[2] + if (seg[2] > maxY) maxY = seg[2] + x = seg[3]; y = seg[4] + } else if (cmd === 'Z' || cmd === 'z') { + x = firstX; y = firstY + } else { + continue + } + + // Update bbox + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + + // Shoelace formula for direction + if (i > 0) sum += (x - prevX) * (y + prevY) + prevX = x; prevY = y + } + + return { + bbox: { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY }, + isClockwise: sum > 0 + } +} + +/** + * Check if outer bbox contains inner bbox + */ +function bboxContains(outer, inner) { + return outer.minX <= inner.minX + 1 && + outer.minY <= inner.minY + 1 && + outer.maxX >= inner.maxX - 1 && + outer.maxY >= inner.maxY - 1 && + inner.w < outer.w - 1 && + inner.h < outer.h - 1 +} + +/** + * Reverse path direction + */ +function reversePath(pathData, segments) { + if (!segments) segments = new SVGPathCommander(pathData, { round: 0 }).toAbsolute().segments + if (segments.length === 0) return pathData + + const points = [] + let startX = 0, startY = 0, curX = 0, curY = 0 + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + const cmd = seg[0] + if (cmd === 'M') { + startX = curX = seg[1]; startY = curY = seg[2] + points.push({ cmd: 'M', x: seg[1], y: seg[2] }) + } else if (cmd === 'L') { + curX = seg[1]; curY = seg[2] + points.push({ cmd: 'L', x: seg[1], y: seg[2] }) + } else if (cmd === 'H') { + curX = seg[1] + points.push({ cmd: 'L', x: curX, y: curY }) + } else if (cmd === 'V') { + curY = seg[1] + points.push({ cmd: 'L', x: curX, y: curY }) + } else if (cmd === 'C') { + curX = seg[5]; curY = seg[6] + points.push({ cmd: 'C', x1: seg[1], y1: seg[2], x2: seg[3], y2: seg[4], x: seg[5], y: seg[6] }) + } else if (cmd === 'Q') { + curX = seg[3]; curY = seg[4] + points.push({ cmd: 'Q', x1: seg[1], y1: seg[2], x: seg[3], y: seg[4] }) + } else if (cmd === 'Z' || cmd === 'z') { + points.push({ cmd: 'Z', x: startX, y: startY }) } } - - // Calculate signed area using shoelace formula - for (let i = 0; i < points.length - 1; i++) { - sum += (points[i + 1].x - points[i].x) * (points[i + 1].y + points[i].y) + + let hasClose = false + if (points.length > 0 && points[points.length - 1].cmd === 'Z') { + hasClose = true + points.pop() } - - // Positive = clockwise, negative = counterclockwise (in SVG coordinate system where Y increases downward) - return sum > 0 + + if (points.length === 0) return pathData + + const last = points[points.length - 1] + const reversed = [`M${last.x ?? 0} ${last.y ?? 0}`] + + for (let i = points.length - 1; i > 0; i--) { + const curr = points[i], prev = points[i - 1] + const px = prev.x ?? 0, py = prev.y ?? 0 + + if (curr.cmd === 'L' || curr.cmd === 'M') { + reversed.push(`L${px} ${py}`) + } else if (curr.cmd === 'C') { + reversed.push(`C${curr.x2} ${curr.y2} ${curr.x1} ${curr.y1} ${px} ${py}`) + } else if (curr.cmd === 'Q') { + reversed.push(`Q${curr.x1} ${curr.y1} ${px} ${py}`) + } + } + + if (hasClose) reversed.push('Z') + return reversed.join(' ') } -/** - * Process SVG file - */ export function fixOutlineFile(inputPath, outputPath = null) { const content = fs.readFileSync(inputPath, 'utf-8') const fixed = fixOutline(content) @@ -83,12 +270,11 @@ export function fixOutlineFile(inputPath, outputPath = null) { return fixed } -// CLI support -if (process.argv[1] && process.argv[1].endsWith('fix-outline.mjs')) { +if (process.argv[1]?.endsWith('fix-outline.mjs')) { const file = process.argv[2] if (file) { console.log(`Correcting outline for ${file}`) fixOutlineFile(file) - console.log('Finished fixing svg outline directions!') + console.log('Finished!') } -} +} \ No newline at end of file