Refactor SVG outline fixing logic to improve path direction handling and optimize performance.
This commit is contained in:
parent
f76e44c631
commit
75a447c733
|
|
@ -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 = `<!--!cache:${crypto.createHash('sha1').update(finalContent).digest("hex")}-->`
|
||||
|
||||
// Save file
|
||||
|
|
|
|||
|
|
@ -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 = /<path[^>]*\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 = /<path[^>]*\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!')
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue