@brickflow/cli 0.0.41 → 0.0.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +34 -0
- package/package.json +5 -2
- package/src/svg/index.js +1 -1
- package/src/svg-fix/convert.js +777 -0
- package/src/svg-fix/index.js +146 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @brickflow/cli
|
|
2
2
|
|
|
3
|
+
## 0.0.44
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Svg fix
|
|
8
|
+
|
|
9
|
+
## 0.0.43
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Add svg-fix script
|
|
14
|
+
|
|
15
|
+
## 0.0.42
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Fix file and type generate
|
|
20
|
+
|
|
3
21
|
## 0.0.41
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @brickflow/cli
|
|
2
|
+
|
|
3
|
+
## Fix SVG files
|
|
4
|
+
|
|
5
|
+
Recursively expand SVG strokes to filled outlines. The command uses SVGO to resolve CSS and [@napi-rs/canvas](https://github.com/Brooooooklyn/canvas) for vector stroke geometry. It does not rasterize or trace artwork.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
brick svg-fix ./icons/logo.svg
|
|
9
|
+
brick svg-fix ./icons
|
|
10
|
+
brick svg-fix ./icons ./assets/icons ./logo.svg
|
|
11
|
+
brick svg-fix --path ./icons --path ./assets/icons
|
|
12
|
+
brick svg-fix --verbose ./icons
|
|
13
|
+
brick svg-fix --help
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
From the repository root:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
node ./packages/cli/index.mjs svg-fix ./icons ./assets/icons
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Pass one or more paths as separate arguments, or repeat `--path` (`-p`). Each path can be a directory or an individual SVG file. Relative paths are resolved from the current working directory. Directories are searched recursively, and files with an `.svg` extension (case-insensitive) are processed. Other files and symbolic links discovered inside directories are skipped.
|
|
23
|
+
|
|
24
|
+
SVG files are overwritten at their original paths; there is no `--output` option. Repeated or overlapping paths do not cause a file to be processed more than once. All input paths are scanned before any files are changed. A file that cannot be converted is reported without rewriting it, and processing continues with the remaining files. The final summary includes failed files separately from unchanged files, and the command exits with code 1 if any file failed.
|
|
25
|
+
|
|
26
|
+
`svg-fix` has one responsibility: elements with a visible computed `stroke` are converted to filled outline paths. Elements without strokes are never structurally or geometrically normalized. The root SVG, viewBox, dimensions, groups, definitions, metadata, fill-only shapes, IDs, classes, and transforms remain in place.
|
|
27
|
+
|
|
28
|
+
Stroke detection includes inline CSS, CSS classes, CSS variables, and inherited presentation attributes. Expansion supports paths, lines, polygons, polylines, circles, ellipses, and rectangles, including line caps, joins, miter limits, arbitrary dash arrays, and dash offsets. Transforms are preserved and apply to the generated outline exactly where they applied to the original element.
|
|
29
|
+
|
|
30
|
+
If a stroked element also has a visible fill, its original fill geometry is kept and a separate outline path is inserted beside it. If it has no fill, only that element is replaced by its outline. SVG files with no visible strokes are returned byte-for-byte unchanged. Repeated runs are idempotent.
|
|
31
|
+
|
|
32
|
+
Run `brick svg` first for general SVGO optimization and `brick svg-fix` last for stroke expansion. After all strokes are expanded, later `svg-fix` runs do nothing; the two commands no longer alternate between competing canonical formats.
|
|
33
|
+
|
|
34
|
+
Geometry and stroke lengths must be unitless or use `px`. Non-scaling strokes are rejected because expanding them correctly would require changing transforms; the original file is left untouched on failure.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brickflow/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.44",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
@@ -19,12 +19,15 @@
|
|
|
19
19
|
"@babel/traverse": "8.0.4",
|
|
20
20
|
"@babel/types": "8.0.4",
|
|
21
21
|
"@google/genai": "2.21.0",
|
|
22
|
+
"@napi-rs/canvas": "1.0.8",
|
|
22
23
|
"@vue/compiler-sfc": "3.5.42",
|
|
23
24
|
"fantasticon": "4.1.0",
|
|
24
25
|
"glob": "13.0.6",
|
|
25
26
|
"looks-same": "10.0.1",
|
|
26
27
|
"sharp": "0.35.4",
|
|
27
|
-
"svgo": "4.1.0"
|
|
28
|
+
"svgo": "4.1.0",
|
|
29
|
+
"svgpath": "2.6.0",
|
|
30
|
+
"transformation-matrix": "3.1.0"
|
|
28
31
|
},
|
|
29
32
|
"publishConfig": {
|
|
30
33
|
"access": "public"
|
package/src/svg/index.js
CHANGED
|
@@ -70,7 +70,7 @@ for (let i = 0; i < svgList.length; i++) {
|
|
|
70
70
|
'removeUnknownsAndDefaults', // remove unknown elements content and attributes, remove attributes with default values Yes
|
|
71
71
|
'removeUnusedNS', // remove unused namespaces declaration Yes
|
|
72
72
|
'removeUselessDefs', // remove elements of <defs> without id Yes
|
|
73
|
-
'removeUselessStrokeAndFill',
|
|
73
|
+
{ name: 'removeUselessStrokeAndFill', params: { removeNone: true } },
|
|
74
74
|
// "removeViewBox", // remove viewBox attribute when possible Yes
|
|
75
75
|
// "removeXMLNS", // removes the xmlns attribute (for inline SVG)
|
|
76
76
|
'removeXMLProcInst', // remove XML processing instructions Yes
|
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { Path2D, StrokeCap, StrokeJoin } from '@napi-rs/canvas'
|
|
2
|
+
import { optimize } from 'svgo'
|
|
3
|
+
import svgpath from 'svgpath'
|
|
4
|
+
import { scale } from 'transformation-matrix'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_STYLE = Object.freeze({
|
|
7
|
+
color: '#000',
|
|
8
|
+
fill: '#000',
|
|
9
|
+
'fill-opacity': '1',
|
|
10
|
+
stroke: 'none',
|
|
11
|
+
'stroke-dasharray': 'none',
|
|
12
|
+
'stroke-dashoffset': '0',
|
|
13
|
+
'stroke-linecap': 'butt',
|
|
14
|
+
'stroke-linejoin': 'miter',
|
|
15
|
+
'stroke-miterlimit': '4',
|
|
16
|
+
'stroke-opacity': '1',
|
|
17
|
+
'stroke-width': '1',
|
|
18
|
+
})
|
|
19
|
+
const INHERITED_STYLE = new Set(Object.keys(DEFAULT_STYLE))
|
|
20
|
+
const SHAPES = new Set(['circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect'])
|
|
21
|
+
const STROKE_ATTRIBUTES = new Set([
|
|
22
|
+
'stroke',
|
|
23
|
+
'stroke-dasharray',
|
|
24
|
+
'stroke-dashoffset',
|
|
25
|
+
'stroke-linecap',
|
|
26
|
+
'stroke-linejoin',
|
|
27
|
+
'stroke-miterlimit',
|
|
28
|
+
'stroke-opacity',
|
|
29
|
+
'stroke-width',
|
|
30
|
+
])
|
|
31
|
+
const GEOMETRY_ATTRIBUTES = new Set([
|
|
32
|
+
'cx',
|
|
33
|
+
'cy',
|
|
34
|
+
'height',
|
|
35
|
+
'points',
|
|
36
|
+
'r',
|
|
37
|
+
'rx',
|
|
38
|
+
'ry',
|
|
39
|
+
'width',
|
|
40
|
+
'x',
|
|
41
|
+
'x1',
|
|
42
|
+
'x2',
|
|
43
|
+
'y',
|
|
44
|
+
'y1',
|
|
45
|
+
'y2',
|
|
46
|
+
])
|
|
47
|
+
const OUTLINE_CONTEXT_ATTRIBUTES = new Set([
|
|
48
|
+
'clip-path',
|
|
49
|
+
'display',
|
|
50
|
+
'filter',
|
|
51
|
+
'mask',
|
|
52
|
+
'opacity',
|
|
53
|
+
'transform',
|
|
54
|
+
'visibility',
|
|
55
|
+
])
|
|
56
|
+
const STROKE_SCALE = 128
|
|
57
|
+
|
|
58
|
+
export class SvgFixError extends Error {
|
|
59
|
+
constructor(code, message) {
|
|
60
|
+
super(`${code}: ${message}`)
|
|
61
|
+
this.code = code
|
|
62
|
+
this.name = 'SvgFixError'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Expand visible strokes and leave every stroke-free SVG byte-for-byte intact.
|
|
68
|
+
* Nodes without a computed stroke are never structurally or geometrically changed.
|
|
69
|
+
*/
|
|
70
|
+
export function convertSvg(source, options = {}) {
|
|
71
|
+
const diagnostics = createDiagnostics(options.onDiagnostic)
|
|
72
|
+
const descriptors = analyzeShapes(source)
|
|
73
|
+
if (!descriptors.some(({ convert }) => convert)) {
|
|
74
|
+
return source
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sourceShapes = collectSourceShapes(source)
|
|
78
|
+
const tokens = scanShapeTokens(source)
|
|
79
|
+
if (sourceShapes.length !== descriptors.length || tokens.length !== descriptors.length) {
|
|
80
|
+
fatal('INVALID_SVG', 'Could not correlate parsed SVG elements with source tags.')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const replacements = []
|
|
84
|
+
for (let index = 0; index < descriptors.length; index += 1) {
|
|
85
|
+
const descriptor = descriptors[index]
|
|
86
|
+
if (!descriptor.convert) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
const nodes = expandStrokeNode(sourceShapes[index], descriptor, diagnostics)
|
|
90
|
+
replacements.push({
|
|
91
|
+
end: tokens[index].end,
|
|
92
|
+
start: tokens[index].start,
|
|
93
|
+
value: nodes.map(serializeElement).join(''),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return applyReplacements(source, replacements)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function analyzeShapes(source) {
|
|
100
|
+
let document
|
|
101
|
+
try {
|
|
102
|
+
optimize(source, {
|
|
103
|
+
plugins: [
|
|
104
|
+
{ name: 'inlineStyles', params: { onlyMatchedOnce: false } },
|
|
105
|
+
'convertStyleToAttrs',
|
|
106
|
+
{
|
|
107
|
+
fn(root) {
|
|
108
|
+
document = root
|
|
109
|
+
},
|
|
110
|
+
name: 'collect-computed-svg',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
})
|
|
114
|
+
} catch (error) {
|
|
115
|
+
fatal('INVALID_SVG', error.message)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const descriptors = []
|
|
119
|
+
collectShapeDescriptors(document, createStyleState(), descriptors)
|
|
120
|
+
return descriptors
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyReplacements(source, replacements) {
|
|
124
|
+
let result = source
|
|
125
|
+
for (const replacement of replacements.sort((first, second) => second.start - first.start)) {
|
|
126
|
+
result = `${result.slice(0, replacement.start)}${replacement.value}${result.slice(replacement.end)}`
|
|
127
|
+
}
|
|
128
|
+
return result
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function collectElements(node, callback) {
|
|
132
|
+
if (node.type === 'element') {
|
|
133
|
+
callback(node)
|
|
134
|
+
}
|
|
135
|
+
for (const child of node.children ?? []) {
|
|
136
|
+
collectElements(child, callback)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function collectShapeDescriptors(node, parentState, descriptors) {
|
|
141
|
+
if (node.type !== 'element' && node.type !== 'root') {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const state = node.type === 'element' ? computeStyle(node, parentState) : parentState
|
|
145
|
+
if (node.type === 'element' && SHAPES.has(node.name)) {
|
|
146
|
+
const strokeWidth = length(state.style['stroke-width'], 'stroke-width')
|
|
147
|
+
const strokeOpacity = opacity(state.style['stroke-opacity'], 'stroke-opacity')
|
|
148
|
+
const stroke = resolveVariables(state.style.stroke, state.variables).trim()
|
|
149
|
+
descriptors.push({
|
|
150
|
+
convert: strokeWidth > 0 && strokeOpacity > 0 && !['none', 'transparent'].includes(stroke.toLowerCase()),
|
|
151
|
+
node,
|
|
152
|
+
state,
|
|
153
|
+
stroke,
|
|
154
|
+
strokeOpacity,
|
|
155
|
+
strokeWidth,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
for (const child of node.children ?? []) {
|
|
159
|
+
collectShapeDescriptors(child, state, descriptors)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function collectSourceShapes(source) {
|
|
164
|
+
let document
|
|
165
|
+
try {
|
|
166
|
+
optimize(source, {
|
|
167
|
+
plugins: [
|
|
168
|
+
{
|
|
169
|
+
fn(root) {
|
|
170
|
+
document = root
|
|
171
|
+
},
|
|
172
|
+
name: 'collect-source-svg',
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
})
|
|
176
|
+
} catch (error) {
|
|
177
|
+
fatal('INVALID_SVG', error.message)
|
|
178
|
+
}
|
|
179
|
+
const shapes = []
|
|
180
|
+
collectElements(document, (node) => {
|
|
181
|
+
if (SHAPES.has(node.name)) {
|
|
182
|
+
shapes.push(node)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
return shapes
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function computeStyle(node, parentState) {
|
|
189
|
+
const state = createStyleState(parentState)
|
|
190
|
+
const declarations = parseStyle(node.attributes.style)
|
|
191
|
+
for (const [name, value] of Object.entries(declarations)) {
|
|
192
|
+
if (name.startsWith('--')) {
|
|
193
|
+
state.variables[name] = resolveVariables(value, state.variables)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const [name, value] of Object.entries(node.attributes)) {
|
|
197
|
+
if (name.startsWith('--')) {
|
|
198
|
+
state.variables[name] = resolveVariables(value, state.variables)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const name of INHERITED_STYLE) {
|
|
202
|
+
const rawValue = declarations[name] ?? node.attributes[name]
|
|
203
|
+
if (rawValue !== undefined && rawValue !== 'inherit') {
|
|
204
|
+
state.style[name] = resolveVariables(rawValue, state.variables)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return state
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function convertNodeToOutline(node, descriptor, data) {
|
|
211
|
+
node.name = 'path'
|
|
212
|
+
for (const name of GEOMETRY_ATTRIBUTES) {
|
|
213
|
+
delete node.attributes[name]
|
|
214
|
+
}
|
|
215
|
+
removeStrokeAttributes(node)
|
|
216
|
+
node.attributes.d = data
|
|
217
|
+
node.attributes.style = outlineStyle(node.attributes.style, descriptor, false)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function createDiagnostics(callback) {
|
|
221
|
+
return {
|
|
222
|
+
emit(code, message, level = 'fix') {
|
|
223
|
+
callback?.({ code, level, message })
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function createOutlineNode(sourceNode, descriptor, data) {
|
|
229
|
+
const attributes = { d: data }
|
|
230
|
+
for (const [name, value] of Object.entries(descriptor.node.attributes)) {
|
|
231
|
+
if (OUTLINE_CONTEXT_ATTRIBUTES.has(name)) {
|
|
232
|
+
attributes[name] = value
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
attributes.style = outlineStyle(undefined, descriptor, true)
|
|
236
|
+
return { attributes, children: [], name: 'path', type: 'element' }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function createStyleState(parent) {
|
|
240
|
+
return {
|
|
241
|
+
style: { ...(parent?.style ?? DEFAULT_STYLE) },
|
|
242
|
+
variables: { ...(parent?.variables ?? {}) },
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function curveFlatness([start, first, second, end]) {
|
|
247
|
+
return Math.max(pointLineDistance(first, start, end), pointLineDistance(second, start, end))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function dashPath(path, pattern, offset) {
|
|
251
|
+
const contours = flattenPath(path.toSVGString())
|
|
252
|
+
const result = new Path2D()
|
|
253
|
+
const cycle = pattern.reduce((sum, value) => sum + value, 0)
|
|
254
|
+
let normalizedOffset = ((offset % cycle) + cycle) % cycle
|
|
255
|
+
let patternIndex = 0
|
|
256
|
+
while (normalizedOffset >= pattern[patternIndex] && pattern[patternIndex] > 0) {
|
|
257
|
+
normalizedOffset -= pattern[patternIndex]
|
|
258
|
+
patternIndex = (patternIndex + 1) % pattern.length
|
|
259
|
+
}
|
|
260
|
+
for (const points of contours) {
|
|
261
|
+
let remaining = pattern[patternIndex] - normalizedOffset
|
|
262
|
+
let drawing = patternIndex % 2 === 0
|
|
263
|
+
let started = false
|
|
264
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
265
|
+
let [x, y] = points[index - 1]
|
|
266
|
+
const [endX, endY] = points[index]
|
|
267
|
+
let distance = Math.hypot(endX - x, endY - y)
|
|
268
|
+
while (distance > 1e-10) {
|
|
269
|
+
if (remaining <= 1e-10) {
|
|
270
|
+
patternIndex = (patternIndex + 1) % pattern.length
|
|
271
|
+
remaining = pattern[patternIndex]
|
|
272
|
+
drawing = patternIndex % 2 === 0
|
|
273
|
+
started = false
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
const step = Math.min(distance, remaining)
|
|
277
|
+
const ratio = step / distance
|
|
278
|
+
const nextX = x + (endX - x) * ratio
|
|
279
|
+
const nextY = y + (endY - y) * ratio
|
|
280
|
+
if (drawing) {
|
|
281
|
+
if (!started) {
|
|
282
|
+
result.moveTo(x, y)
|
|
283
|
+
}
|
|
284
|
+
result.lineTo(nextX, nextY)
|
|
285
|
+
started = true
|
|
286
|
+
} else {
|
|
287
|
+
started = false
|
|
288
|
+
}
|
|
289
|
+
x = nextX
|
|
290
|
+
y = nextY
|
|
291
|
+
distance -= step
|
|
292
|
+
remaining -= step
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return result
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function disableStroke(node) {
|
|
300
|
+
removeStrokeAttributes(node)
|
|
301
|
+
node.attributes.style = setStyleProperties(node.attributes.style, { stroke: 'none!important' })
|
|
302
|
+
return node
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function enumValue(value, values, name) {
|
|
306
|
+
if (!Object.hasOwn(values, value)) {
|
|
307
|
+
fatal('INVALID_STYLE', `Unsupported ${name}: ${value}.`)
|
|
308
|
+
}
|
|
309
|
+
return values[value]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function escapeAttribute(value) {
|
|
313
|
+
return String(value)
|
|
314
|
+
.replaceAll('&', '&')
|
|
315
|
+
.replaceAll('"', '"')
|
|
316
|
+
.replaceAll('<', '<')
|
|
317
|
+
.replaceAll('>', '>')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function expandStrokeNode(node, descriptor, diagnostics) {
|
|
321
|
+
if (node.children.length > 0) {
|
|
322
|
+
fatal('INVALID_SVG', `Stroked <${node.name}> must not contain child content.`)
|
|
323
|
+
}
|
|
324
|
+
if (descriptor.node.attributes['vector-effect'] === 'non-scaling-stroke') {
|
|
325
|
+
fatal('UNSUPPORTED_VECTOR_EFFECT', 'non-scaling-stroke cannot be converted without changing transforms.')
|
|
326
|
+
}
|
|
327
|
+
const geometry = shapePath(descriptor.node)
|
|
328
|
+
const outline = strokeToPath(geometry, descriptor)
|
|
329
|
+
const outlineData = normalizePathData(outline.toSVGString())
|
|
330
|
+
if (!outlineData) {
|
|
331
|
+
return [disableStroke(node)]
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const fillVisible =
|
|
335
|
+
!['none', 'transparent'].includes(descriptor.state.style.fill.toLowerCase()) &&
|
|
336
|
+
opacity(descriptor.state.style['fill-opacity'], 'fill-opacity') > 0
|
|
337
|
+
diagnostics.emit('STROKE_TO_PATH', `Expanded stroke on <${node.name}> without changing stroke-free elements.`)
|
|
338
|
+
|
|
339
|
+
if (!fillVisible) {
|
|
340
|
+
convertNodeToOutline(node, descriptor, outlineData)
|
|
341
|
+
return [node]
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
disableStroke(node)
|
|
345
|
+
return [node, createOutlineNode(node, descriptor, outlineData)]
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function fatal(code, message) {
|
|
349
|
+
throw new SvgFixError(code, message)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function findTagEnd(source, start) {
|
|
353
|
+
let quote
|
|
354
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
355
|
+
const character = source[index]
|
|
356
|
+
if (quote) {
|
|
357
|
+
if (character === quote) {
|
|
358
|
+
quote = undefined
|
|
359
|
+
}
|
|
360
|
+
} else if (character === '"' || character === "'") {
|
|
361
|
+
quote = character
|
|
362
|
+
} else if (character === '>') {
|
|
363
|
+
return index
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
fatal('INVALID_SVG', `Unclosed tag at source offset ${start}.`)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function flattenCubic(from, control1, control2, to, output, depth = 0) {
|
|
370
|
+
if (depth >= 12 || curveFlatness([from, control1, control2, to]) <= 0.0025) {
|
|
371
|
+
output.push(to)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
const a = midpoint(from, control1)
|
|
375
|
+
const b = midpoint(control1, control2)
|
|
376
|
+
const c = midpoint(control2, to)
|
|
377
|
+
const d = midpoint(a, b)
|
|
378
|
+
const e = midpoint(b, c)
|
|
379
|
+
const middle = midpoint(d, e)
|
|
380
|
+
flattenCubic(from, a, d, middle, output, depth + 1)
|
|
381
|
+
flattenCubic(middle, e, c, to, output, depth + 1)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function flattenPath(data) {
|
|
385
|
+
const parsed = validatePath(data).abs().unshort().unarc()
|
|
386
|
+
const contours = []
|
|
387
|
+
let points = []
|
|
388
|
+
let current = [0, 0]
|
|
389
|
+
let start = [0, 0]
|
|
390
|
+
const addPoint = (point) => {
|
|
391
|
+
if (!points.length || Math.hypot(point[0] - points.at(-1)[0], point[1] - points.at(-1)[1]) > 1e-10) {
|
|
392
|
+
points.push(point)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const segment of parsed.segments) {
|
|
396
|
+
const command = segment[0]
|
|
397
|
+
if (command === 'M') {
|
|
398
|
+
if (points.length > 1) {
|
|
399
|
+
contours.push(points)
|
|
400
|
+
}
|
|
401
|
+
current = [segment[1], segment[2]]
|
|
402
|
+
start = current
|
|
403
|
+
points = [current]
|
|
404
|
+
} else if (command === 'L') {
|
|
405
|
+
current = [segment[1], segment[2]]
|
|
406
|
+
addPoint(current)
|
|
407
|
+
} else if (command === 'C') {
|
|
408
|
+
const to = [segment[5], segment[6]]
|
|
409
|
+
flattenCubic(current, [segment[1], segment[2]], [segment[3], segment[4]], to, points)
|
|
410
|
+
current = to
|
|
411
|
+
} else if (command === 'Q') {
|
|
412
|
+
const to = [segment[3], segment[4]]
|
|
413
|
+
flattenQuadratic(current, [segment[1], segment[2]], to, points)
|
|
414
|
+
current = to
|
|
415
|
+
} else if (command === 'Z') {
|
|
416
|
+
addPoint(start)
|
|
417
|
+
current = start
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (points.length > 1) {
|
|
421
|
+
contours.push(points)
|
|
422
|
+
}
|
|
423
|
+
return contours
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function flattenQuadratic(from, control, to, output) {
|
|
427
|
+
const control1 = [from[0] + (2 / 3) * (control[0] - from[0]), from[1] + (2 / 3) * (control[1] - from[1])]
|
|
428
|
+
const control2 = [to[0] + (2 / 3) * (control[0] - to[0]), to[1] + (2 / 3) * (control[1] - to[1])]
|
|
429
|
+
flattenCubic(from, control1, control2, to, output)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function formatNumber(number) {
|
|
433
|
+
return String(Number(number.toFixed(4)))
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function length(value, name) {
|
|
437
|
+
const text = String(value).trim()
|
|
438
|
+
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?(?:px)?$/i.test(text)) {
|
|
439
|
+
fatal('INVALID_LENGTH', `${name} must be a number or px length, got "${text}".`)
|
|
440
|
+
}
|
|
441
|
+
const result = Number(text.replace(/px$/i, ''))
|
|
442
|
+
if (!Number.isFinite(result)) {
|
|
443
|
+
fatal('INVALID_LENGTH', `${name} is not finite.`)
|
|
444
|
+
}
|
|
445
|
+
return result
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function midpoint(first, second) {
|
|
449
|
+
return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function normalizePathData(data) {
|
|
453
|
+
if (!data) {
|
|
454
|
+
return ''
|
|
455
|
+
}
|
|
456
|
+
return validatePath(data)
|
|
457
|
+
.abs()
|
|
458
|
+
.unshort()
|
|
459
|
+
.unarc()
|
|
460
|
+
.round(4)
|
|
461
|
+
.toString()
|
|
462
|
+
.replace(/-0(?:\.0+)?(?=[\s,]|$)/g, '0')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function numberList(value, name) {
|
|
466
|
+
const text = String(value).trim()
|
|
467
|
+
if (!text) {
|
|
468
|
+
return []
|
|
469
|
+
}
|
|
470
|
+
return text.split(/[\s,]+/).map((item) => length(item, name))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function opacity(value, name) {
|
|
474
|
+
const text = String(value).trim()
|
|
475
|
+
const result = text.endsWith('%') ? Number.parseFloat(text) / 100 : length(text, name)
|
|
476
|
+
if (!Number.isFinite(result) || result < 0 || result > 1) {
|
|
477
|
+
fatal('INVALID_STYLE', `${name} must be between 0 and 1.`)
|
|
478
|
+
}
|
|
479
|
+
return result
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function outlineStyle(currentStyle, descriptor, copyLocalColor) {
|
|
483
|
+
const properties = {
|
|
484
|
+
fill: `${descriptor.stroke}!important`,
|
|
485
|
+
'fill-opacity': `${formatNumber(descriptor.strokeOpacity)}!important`,
|
|
486
|
+
stroke: 'none!important',
|
|
487
|
+
}
|
|
488
|
+
if (
|
|
489
|
+
copyLocalColor &&
|
|
490
|
+
descriptor.stroke.toLowerCase() === 'currentcolor' &&
|
|
491
|
+
descriptor.node.attributes.color !== undefined
|
|
492
|
+
) {
|
|
493
|
+
properties.color = `${descriptor.state.style.color}!important`
|
|
494
|
+
}
|
|
495
|
+
return setStyleProperties(currentStyle, properties, new Set([...STROKE_ATTRIBUTES, 'fill', 'fill-opacity']))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function parseDashArray(value) {
|
|
499
|
+
if (!value || value === 'none') {
|
|
500
|
+
return null
|
|
501
|
+
}
|
|
502
|
+
const dash = numberList(value, 'stroke-dasharray')
|
|
503
|
+
if (dash.length === 0 || dash.some((number) => number < 0) || dash.every((number) => number === 0)) {
|
|
504
|
+
fatal('INVALID_STROKE', 'stroke-dasharray needs non-negative lengths and at least one positive length.')
|
|
505
|
+
}
|
|
506
|
+
if (dash.length % 2 === 1) {
|
|
507
|
+
dash.push(...dash)
|
|
508
|
+
}
|
|
509
|
+
return dash
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function parseStyle(value) {
|
|
513
|
+
return Object.fromEntries(parseStyleEntries(value))
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function parseStyleEntries(value) {
|
|
517
|
+
if (!value) {
|
|
518
|
+
return []
|
|
519
|
+
}
|
|
520
|
+
const entries = []
|
|
521
|
+
for (const declaration of value.split(';')) {
|
|
522
|
+
if (!declaration.trim()) {
|
|
523
|
+
continue
|
|
524
|
+
}
|
|
525
|
+
const separator = declaration.indexOf(':')
|
|
526
|
+
if (separator < 1) {
|
|
527
|
+
fatal('INVALID_STYLE', `Invalid style declaration: ${declaration}`)
|
|
528
|
+
}
|
|
529
|
+
entries.push([declaration.slice(0, separator).trim(), declaration.slice(separator + 1).trim()])
|
|
530
|
+
}
|
|
531
|
+
return entries
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function pointLineDistance(point, start, end) {
|
|
535
|
+
const lineLength = Math.hypot(end[0] - start[0], end[1] - start[1])
|
|
536
|
+
if (lineLength === 0) {
|
|
537
|
+
return Math.hypot(point[0] - start[0], point[1] - start[1])
|
|
538
|
+
}
|
|
539
|
+
return (
|
|
540
|
+
Math.abs(
|
|
541
|
+
(end[1] - start[1]) * point[0] - (end[0] - start[0]) * point[1] + end[0] * start[1] - end[1] * start[0],
|
|
542
|
+
) / lineLength
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function removeStrokeAttributes(node) {
|
|
547
|
+
for (const name of STROKE_ATTRIBUTES) {
|
|
548
|
+
delete node.attributes[name]
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function resolveVariables(value, variables, stack = []) {
|
|
553
|
+
if (typeof value !== 'string' || !value.includes('var(')) {
|
|
554
|
+
return value
|
|
555
|
+
}
|
|
556
|
+
let changed = false
|
|
557
|
+
const result = value.replace(/var\(([^()]*)\)/g, (_, body) => {
|
|
558
|
+
const [rawName, ...fallbackParts] = body.split(',')
|
|
559
|
+
const name = rawName.trim()
|
|
560
|
+
const fallback = fallbackParts.length ? fallbackParts.join(',').trim() : undefined
|
|
561
|
+
if (!/^--[\w-]+$/.test(name)) {
|
|
562
|
+
fatal('INVALID_STYLE', `Invalid CSS variable name: ${name}.`)
|
|
563
|
+
}
|
|
564
|
+
if (stack.includes(name)) {
|
|
565
|
+
fatal('INVALID_STYLE', `Circular CSS variable ${name}.`)
|
|
566
|
+
}
|
|
567
|
+
const replacement = variables[name] ?? fallback
|
|
568
|
+
if (replacement === undefined) {
|
|
569
|
+
fatal('INVALID_STYLE', `CSS variable ${name} has no value or fallback.`)
|
|
570
|
+
}
|
|
571
|
+
changed = true
|
|
572
|
+
return resolveVariables(replacement.trim(), variables, [...stack, name])
|
|
573
|
+
})
|
|
574
|
+
if (result.includes('var(') && !changed) {
|
|
575
|
+
fatal('INVALID_STYLE', `Unsupported CSS variable expression: ${value}`)
|
|
576
|
+
}
|
|
577
|
+
return result.includes('var(') ? resolveVariables(result, variables, stack) : result
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function scanShapeTokens(source) {
|
|
581
|
+
const shapes = []
|
|
582
|
+
const stack = []
|
|
583
|
+
let cursor = 0
|
|
584
|
+
while (cursor < source.length) {
|
|
585
|
+
const start = source.indexOf('<', cursor)
|
|
586
|
+
if (start < 0) {
|
|
587
|
+
break
|
|
588
|
+
}
|
|
589
|
+
if (source.startsWith('<!--', start)) {
|
|
590
|
+
cursor = skipMarkup(source, start, '-->')
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
if (source.startsWith('<![CDATA[', start)) {
|
|
594
|
+
cursor = skipMarkup(source, start, ']]>')
|
|
595
|
+
continue
|
|
596
|
+
}
|
|
597
|
+
if (source.startsWith('<?', start)) {
|
|
598
|
+
cursor = skipMarkup(source, start, '?>')
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
if (source.startsWith('<!', start)) {
|
|
602
|
+
cursor = findTagEnd(source, start) + 1
|
|
603
|
+
continue
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const tagEnd = findTagEnd(source, start)
|
|
607
|
+
const tagSource = source.slice(start, tagEnd + 1)
|
|
608
|
+
const closing = /^<\s*\//.test(tagSource)
|
|
609
|
+
const name = tagSource.match(/^<\s*(?:\/\s*)?([\w:.-]+)/)?.[1]
|
|
610
|
+
if (!name) {
|
|
611
|
+
fatal('INVALID_SVG', `Cannot parse tag at source offset ${start}.`)
|
|
612
|
+
}
|
|
613
|
+
if (closing) {
|
|
614
|
+
const opened = stack.pop()
|
|
615
|
+
if (!opened || opened.name !== name) {
|
|
616
|
+
fatal('INVALID_SVG', `Unexpected closing tag </${name}>.`)
|
|
617
|
+
}
|
|
618
|
+
opened.end = tagEnd + 1
|
|
619
|
+
} else {
|
|
620
|
+
const selfClosing = /\/\s*>$/.test(tagSource)
|
|
621
|
+
const token = { end: selfClosing ? tagEnd + 1 : undefined, name, start }
|
|
622
|
+
if (SHAPES.has(name)) {
|
|
623
|
+
shapes.push(token)
|
|
624
|
+
}
|
|
625
|
+
if (!selfClosing) {
|
|
626
|
+
stack.push(token)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
cursor = tagEnd + 1
|
|
630
|
+
}
|
|
631
|
+
if (stack.length > 0 || shapes.some(({ end }) => end === undefined)) {
|
|
632
|
+
fatal('INVALID_SVG', 'SVG contains unclosed elements.')
|
|
633
|
+
}
|
|
634
|
+
return shapes
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function serializeElement(node) {
|
|
638
|
+
const attributes = Object.entries(node.attributes)
|
|
639
|
+
.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
|
|
640
|
+
.join('')
|
|
641
|
+
return `<${node.name}${attributes}/>`
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function setStyleProperties(style, properties, removed = STROKE_ATTRIBUTES) {
|
|
645
|
+
const declarations = parseStyleEntries(style).filter(([name]) => !removed.has(name))
|
|
646
|
+
for (const [name, value] of Object.entries(properties)) {
|
|
647
|
+
declarations.push([name, value])
|
|
648
|
+
}
|
|
649
|
+
return declarations.map(([name, value]) => `${name}:${value}`).join(';')
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function shapePath(node) {
|
|
653
|
+
const attributes = node.attributes
|
|
654
|
+
const number = (name, fallback = 0) => length(attributes[name] ?? fallback, name)
|
|
655
|
+
const result = new Path2D()
|
|
656
|
+
switch (node.name) {
|
|
657
|
+
case 'circle':
|
|
658
|
+
case 'ellipse': {
|
|
659
|
+
const radiusX = node.name === 'circle' ? number('r') : number('rx')
|
|
660
|
+
const radiusY = node.name === 'circle' ? radiusX : number('ry')
|
|
661
|
+
if (radiusX < 0 || radiusY < 0) {
|
|
662
|
+
fatal('INVALID_PATH', 'Ellipse radii cannot be negative.')
|
|
663
|
+
}
|
|
664
|
+
if (radiusX > 0 && radiusY > 0) {
|
|
665
|
+
result.ellipse(number('cx'), number('cy'), radiusX, radiusY, 0, 0, Math.PI * 2)
|
|
666
|
+
result.closePath()
|
|
667
|
+
}
|
|
668
|
+
return result
|
|
669
|
+
}
|
|
670
|
+
case 'line':
|
|
671
|
+
result.moveTo(number('x1'), number('y1'))
|
|
672
|
+
result.lineTo(number('x2'), number('y2'))
|
|
673
|
+
return result
|
|
674
|
+
case 'path':
|
|
675
|
+
validatePath(attributes.d ?? '')
|
|
676
|
+
return new Path2D(attributes.d ?? '')
|
|
677
|
+
case 'polygon':
|
|
678
|
+
case 'polyline': {
|
|
679
|
+
const values = numberList(attributes.points ?? '', 'points')
|
|
680
|
+
if (values.length % 2 !== 0) {
|
|
681
|
+
fatal('INVALID_PATH', 'points must contain coordinate pairs.')
|
|
682
|
+
}
|
|
683
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
684
|
+
if (index === 0) {
|
|
685
|
+
result.moveTo(values[index], values[index + 1])
|
|
686
|
+
} else {
|
|
687
|
+
result.lineTo(values[index], values[index + 1])
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (node.name === 'polygon' && values.length >= 4) {
|
|
691
|
+
result.closePath()
|
|
692
|
+
}
|
|
693
|
+
return result
|
|
694
|
+
}
|
|
695
|
+
case 'rect': {
|
|
696
|
+
const x = number('x')
|
|
697
|
+
const y = number('y')
|
|
698
|
+
const width = number('width')
|
|
699
|
+
const height = number('height')
|
|
700
|
+
if (width < 0 || height < 0) {
|
|
701
|
+
fatal('INVALID_PATH', 'Rectangle dimensions cannot be negative.')
|
|
702
|
+
}
|
|
703
|
+
if (width === 0 || height === 0) {
|
|
704
|
+
return result
|
|
705
|
+
}
|
|
706
|
+
const radiusX = Math.min(number('rx', attributes.ry ?? 0), width / 2)
|
|
707
|
+
const radiusY = Math.min(number('ry', attributes.rx ?? 0), height / 2)
|
|
708
|
+
if (radiusX < 0 || radiusY < 0) {
|
|
709
|
+
fatal('INVALID_PATH', 'Rectangle radii cannot be negative.')
|
|
710
|
+
}
|
|
711
|
+
if (radiusX === 0 || radiusY === 0) {
|
|
712
|
+
result.rect(x, y, width, height)
|
|
713
|
+
} else {
|
|
714
|
+
return new Path2D(
|
|
715
|
+
`M${x + radiusX} ${y}H${x + width - radiusX}A${radiusX} ${radiusY} 0 0 1 ${x + width} ${y + radiusY}V${y + height - radiusY}A${radiusX} ${radiusY} 0 0 1 ${x + width - radiusX} ${y + height}H${x + radiusX}A${radiusX} ${radiusY} 0 0 1 ${x} ${y + height - radiusY}V${y + radiusY}A${radiusX} ${radiusY} 0 0 1 ${x + radiusX} ${y}Z`,
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
return result
|
|
719
|
+
}
|
|
720
|
+
default:
|
|
721
|
+
return result
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function skipMarkup(source, start, terminator) {
|
|
726
|
+
const end = source.indexOf(terminator, start + 2)
|
|
727
|
+
if (end < 0) {
|
|
728
|
+
fatal('INVALID_SVG', `Unclosed XML markup at source offset ${start}.`)
|
|
729
|
+
}
|
|
730
|
+
return end + terminator.length
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function strokeToPath(path, descriptor) {
|
|
734
|
+
const style = descriptor.state.style
|
|
735
|
+
const miterLimit = length(style['stroke-miterlimit'], 'stroke-miterlimit')
|
|
736
|
+
if (miterLimit < 1) {
|
|
737
|
+
fatal('INVALID_STROKE', 'stroke-miterlimit must be at least 1.')
|
|
738
|
+
}
|
|
739
|
+
const options = {
|
|
740
|
+
cap: enumValue(
|
|
741
|
+
style['stroke-linecap'],
|
|
742
|
+
{ butt: StrokeCap.Butt, round: StrokeCap.Round, square: StrokeCap.Square },
|
|
743
|
+
'stroke-linecap',
|
|
744
|
+
),
|
|
745
|
+
join: enumValue(
|
|
746
|
+
style['stroke-linejoin'],
|
|
747
|
+
{ bevel: StrokeJoin.Bevel, miter: StrokeJoin.Miter, round: StrokeJoin.Round },
|
|
748
|
+
'stroke-linejoin',
|
|
749
|
+
),
|
|
750
|
+
miterLimit,
|
|
751
|
+
width: descriptor.strokeWidth * STROKE_SCALE,
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
let stroke = new Path2D(path).transform(scale(STROKE_SCALE))
|
|
755
|
+
const dash = parseDashArray(style['stroke-dasharray'])
|
|
756
|
+
if (dash) {
|
|
757
|
+
const offset = length(style['stroke-dashoffset'], 'stroke-dashoffset')
|
|
758
|
+
if (dash.length > 2) {
|
|
759
|
+
stroke = dashPath(path, dash, offset).transform(scale(STROKE_SCALE))
|
|
760
|
+
} else {
|
|
761
|
+
stroke.dash(dash[0] * STROKE_SCALE, dash[1] * STROKE_SCALE, offset * STROKE_SCALE)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
stroke.stroke(options)
|
|
765
|
+
return stroke
|
|
766
|
+
.transform(scale(1 / STROKE_SCALE))
|
|
767
|
+
.simplify()
|
|
768
|
+
.asWinding()
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function validatePath(data) {
|
|
772
|
+
const parsed = svgpath(data)
|
|
773
|
+
if (parsed.err || parsed.segments.some((segment) => segment.slice(1).some((value) => !Number.isFinite(value)))) {
|
|
774
|
+
fatal('INVALID_PATH', parsed.err || 'Path contains non-finite coordinates.')
|
|
775
|
+
}
|
|
776
|
+
return parsed
|
|
777
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
await main()
|
|
7
|
+
} catch (error) {
|
|
8
|
+
console.error(`SVG fix failed: ${error.message}`)
|
|
9
|
+
process.exitCode = 1
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function collectSvgFiles(input, files, visited) {
|
|
13
|
+
if (visited.has(input)) {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
visited.add(input)
|
|
18
|
+
const inputStat = await stat(input)
|
|
19
|
+
|
|
20
|
+
if (inputStat.isFile() && path.extname(input).toLowerCase() === '.svg') {
|
|
21
|
+
files.add(input)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!inputStat.isDirectory()) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const entries = await readdir(input, { withFileTypes: true })
|
|
30
|
+
|
|
31
|
+
for (const entry of entries.sort((first, second) => first.name.localeCompare(second.name))) {
|
|
32
|
+
const entryPath = path.join(input, entry.name)
|
|
33
|
+
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
await collectSvgFiles(entryPath, files, visited)
|
|
36
|
+
} else if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.svg') {
|
|
37
|
+
files.add(entryPath)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const { positionals, values } = parseArgs({
|
|
44
|
+
allowPositionals: true,
|
|
45
|
+
args: process.argv.slice(3),
|
|
46
|
+
options: {
|
|
47
|
+
help: { short: 'h', type: 'boolean' },
|
|
48
|
+
path: { multiple: true, short: 'p', type: 'string' },
|
|
49
|
+
verbose: { short: 'v', type: 'boolean' },
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (values.help) {
|
|
54
|
+
printHelp()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const inputs = [...positionals, ...(values.path ?? [])]
|
|
59
|
+
|
|
60
|
+
if (inputs.length === 0 || inputs.some((input) => input.length === 0)) {
|
|
61
|
+
printHelp()
|
|
62
|
+
throw new Error('Provide at least one path to a directory or SVG file.')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const files = new Set()
|
|
66
|
+
const visited = new Set()
|
|
67
|
+
|
|
68
|
+
for (const input of inputs) {
|
|
69
|
+
await collectSvgFiles(await realpath(path.resolve(input)), files, visited)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const svgFiles = [...files]
|
|
73
|
+
|
|
74
|
+
if (svgFiles.length === 0) {
|
|
75
|
+
console.log('No SVG files found in the supplied paths')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { convertSvg } = await import('./convert.js')
|
|
80
|
+
let changed = 0
|
|
81
|
+
let failed = 0
|
|
82
|
+
|
|
83
|
+
console.log(`Fixing ${svgFiles.length} SVG file(s) in place`)
|
|
84
|
+
|
|
85
|
+
for (const [index, source] of svgFiles.entries()) {
|
|
86
|
+
const relativePath = path.relative(process.cwd(), source)
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const original = await readFile(source, 'utf8')
|
|
90
|
+
const diagnostics = []
|
|
91
|
+
const fixed = convertSvg(original, {
|
|
92
|
+
onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
|
|
93
|
+
})
|
|
94
|
+
for (const diagnostic of diagnostics) {
|
|
95
|
+
if (diagnostic.level === 'warning' || values.verbose) {
|
|
96
|
+
console.warn(` ${diagnostic.level}: ${diagnostic.code}: ${diagnostic.message}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (fixed === original) {
|
|
100
|
+
console.log(`[${index + 1}/${svgFiles.length}] unchanged: ${relativePath}`)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
await writeFile(source, fixed)
|
|
104
|
+
changed += 1
|
|
105
|
+
} catch (error) {
|
|
106
|
+
failed += 1
|
|
107
|
+
console.error(`[${index + 1}/${svgFiles.length}] failed: ${relativePath}: ${error.message}`)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(`[${index + 1}/${svgFiles.length}] ${relativePath}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(
|
|
115
|
+
`Fixed ${changed} SVG file(s); unchanged ${svgFiles.length - changed - failed}${failed ? `; failed ${failed}` : ''}`,
|
|
116
|
+
)
|
|
117
|
+
if (failed > 0) {
|
|
118
|
+
process.exitCode = 1
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printHelp() {
|
|
123
|
+
console.log(`brick svg-fix <path...>
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
brick svg-fix ./icons/logo.svg
|
|
127
|
+
brick svg-fix ./icons
|
|
128
|
+
brick svg-fix ./icons ./assets/icons ./logo.svg
|
|
129
|
+
brick svg-fix --path ./icons --path ./assets/icons
|
|
130
|
+
|
|
131
|
+
Options:
|
|
132
|
+
-p, --path Directory or SVG file; may be repeated
|
|
133
|
+
-v, --verbose Also print applied auto-fixes
|
|
134
|
+
-h, --help Show this help
|
|
135
|
+
|
|
136
|
+
Notes:
|
|
137
|
+
Converts only elements with a visible stroke into filled outline paths
|
|
138
|
+
Paths are resolved from the current working directory
|
|
139
|
+
SVG files are overwritten in place; no output directory is needed
|
|
140
|
+
Each SVG is processed once, even when supplied paths overlap
|
|
141
|
+
Fill-only elements, groups, defs, metadata, viewBox and transforms are preserved
|
|
142
|
+
SVG files without visible strokes are left byte-for-byte unchanged
|
|
143
|
+
Run brick svg first and brick svg-fix last
|
|
144
|
+
Exits with code 1 if any file fails
|
|
145
|
+
Other files and symbolic links discovered inside directories are skipped`)
|
|
146
|
+
}
|