@brickflow/cli 0.0.43 → 0.0.45

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @brickflow/cli
2
2
 
3
+ ## 0.0.45
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix icon sizes and text flow
8
+
9
+ ## 0.0.44
10
+
11
+ ### Patch Changes
12
+
13
+ - Svg fix
14
+
3
15
  ## 0.0.43
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -2,21 +2,33 @@
2
2
 
3
3
  ## Fix SVG files
4
4
 
5
- Recursively process SVG files in place with [oslllo-svg-fixer](https://github.com/oslllo/svg-fixer), converting strokes to filled paths for icon fonts.
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
6
 
7
7
  ```sh
8
+ brick svg-fix ./icons/logo.svg
8
9
  brick svg-fix ./icons
9
10
  brick svg-fix ./icons ./assets/icons ./logo.svg
10
11
  brick svg-fix --path ./icons --path ./assets/icons
12
+ brick svg-fix --verbose ./icons
11
13
  brick svg-fix --help
12
14
  ```
13
15
 
14
16
  From the repository root:
15
17
 
16
18
  ```sh
17
- pnpm svg-fix ./icons ./assets/icons
19
+ node ./packages/cli/index.mjs svg-fix ./icons ./assets/icons
18
20
  ```
19
21
 
20
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.
21
23
 
22
- 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. Processing stops with exit code 1 if a file fails; files already processed remain changed.
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,9 +1,16 @@
1
1
  {
2
2
  "name": "@brickflow/cli",
3
- "version": "0.0.43",
3
+ "version": "0.0.45",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./icon": {
10
+ "types": "./src/icon/generate.d.ts",
11
+ "import": "./src/icon/generate.js"
12
+ }
13
+ },
7
14
  "bin": {
8
15
  "brick": "./index.mjs"
9
16
  },
@@ -19,13 +26,15 @@
19
26
  "@babel/traverse": "8.0.4",
20
27
  "@babel/types": "8.0.4",
21
28
  "@google/genai": "2.21.0",
29
+ "@napi-rs/canvas": "1.0.8",
22
30
  "@vue/compiler-sfc": "3.5.42",
23
31
  "fantasticon": "4.1.0",
24
32
  "glob": "13.0.6",
25
33
  "looks-same": "10.0.1",
26
- "oslllo-svg-fixer": "6.0.1",
27
34
  "sharp": "0.35.4",
28
- "svgo": "4.1.0"
35
+ "svgo": "4.1.0",
36
+ "svgpath": "2.6.0",
37
+ "transformation-matrix": "3.1.0"
29
38
  },
30
39
  "publishConfig": {
31
40
  "access": "public"
@@ -0,0 +1,14 @@
1
+ export interface GeneratedIconFiles {
2
+ cssPath: string
3
+ minifiedCssPath: string
4
+ }
5
+
6
+ export interface GenerateIconsOptions {
7
+ inputDir: string
8
+ name: string
9
+ outputDir: string
10
+ }
11
+
12
+ export declare const getIconFontCssPath: (outputDir: string, name: string) => string
13
+
14
+ export declare function generateIcons(options: GenerateIconsOptions): Promise<GeneratedIconFiles>
@@ -0,0 +1,77 @@
1
+ import { FontAssetType, generateFonts, OtherAssetType } from 'fantasticon'
2
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { join, parse } from 'node:path'
4
+
5
+ export const getIconFontCssPath = (outputDir, name) => join(outputDir, `${name}.minify.css`)
6
+
7
+ export async function generateIcons({ inputDir, name, outputDir }) {
8
+ await mkdir(outputDir, { recursive: true })
9
+ await generateFonts({
10
+ assetTypes: [OtherAssetType.CSS, OtherAssetType.JSON],
11
+ fontsUrl: '.',
12
+ fontTypes: [FontAssetType.EOT, FontAssetType.WOFF2, FontAssetType.WOFF],
13
+ formatOptions: {
14
+ json: {
15
+ indent: 2,
16
+ },
17
+ },
18
+ inputDir,
19
+ name,
20
+ normalize: true,
21
+ outputDir,
22
+ })
23
+
24
+ let cssSourceFile
25
+
26
+ for (const file of [`${name}.css`, 'icons.css', 'icon.css']) {
27
+ try {
28
+ await access(join(outputDir, file))
29
+ cssSourceFile = file
30
+ break
31
+ } catch {
32
+ // Try the next filename supported by fantasticon.
33
+ }
34
+ }
35
+
36
+ if (!cssSourceFile) {
37
+ throw new Error(`CSS output file was not generated for icon set "${name}"`)
38
+ }
39
+
40
+ const cssPath = join(outputDir, cssSourceFile)
41
+ const minifiedCssPath = join(outputDir, `${parse(cssSourceFile).name}.minify.css`)
42
+ const cssContent = await readFile(cssPath, 'utf8')
43
+ const normalizedCssContent = normalizeGeneratedCss(cssContent)
44
+
45
+ if (normalizedCssContent !== cssContent) {
46
+ await writeFile(cssPath, normalizedCssContent)
47
+ }
48
+
49
+ await writeFile(minifiedCssPath, minifyCss(normalizedCssContent))
50
+
51
+ return {
52
+ cssPath,
53
+ minifiedCssPath,
54
+ }
55
+ }
56
+
57
+ function minifyCss(css) {
58
+ return css
59
+ .replace(/\/\*[\s\S]*?\*\//g, '')
60
+ .replace(/\s+/g, ' ')
61
+ .replace(/\s*([{}:;,>+~])\s*/g, '$1')
62
+ .replace(/;\}/g, '}')
63
+ .trim()
64
+ }
65
+
66
+ function normalizeGeneratedCss(css) {
67
+ return `${css
68
+ .replace(/"/g, "'")
69
+ .replace(/^ {4}/gm, ' ')
70
+ .replace(/src: ([^\n]+),\n([^\n]+),\n([^\n]+);/, 'src:\n $1,\n $2,\n $3;')
71
+ .replace(
72
+ /i\[class\^='icon-'\]:before, i\[class\*=' icon-'\]:before \{/,
73
+ "i[class^='icon-']:before,\ni[class*=' icon-']:before {",
74
+ )
75
+ .replace(/\n{3,}/g, '\n\n')
76
+ .trim()}\n`
77
+ }
package/src/icon/index.js CHANGED
@@ -1,8 +1,9 @@
1
- import { FontAssetType, generateFonts, OtherAssetType } from 'fantasticon'
2
1
  import fs from 'fs'
3
2
  import path from 'path'
4
3
  import { fileURLToPath } from 'url'
5
4
 
5
+ import { generateIcons } from './generate.js'
6
+
6
7
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
7
8
  const workspaceRoot = path.resolve(currentDir, '../../../..')
8
9
  const args = process.argv.slice(3)
@@ -37,68 +38,16 @@ if (/[\\/]/.test(iconName)) {
37
38
  throw new Error(`Icon name must not contain path separators: ${iconName}`)
38
39
  }
39
40
 
40
- fs.mkdirSync(outputDir, { recursive: true })
41
-
42
- await generateFonts({
43
- assetTypes: [OtherAssetType.CSS, OtherAssetType.JSON],
44
- fontsUrl: '.',
45
- fontTypes: [FontAssetType.EOT, FontAssetType.WOFF2, FontAssetType.WOFF],
46
- formatOptions: {
47
- json: {
48
- indent: 2,
49
- },
50
- },
41
+ await generateIcons({
51
42
  inputDir,
52
43
  name: iconName,
53
- normalize: true,
54
44
  outputDir,
55
45
  })
56
46
 
57
- const cssSourceFile = [`${iconName}.css`, 'icons.css', 'icon.css'].find((file) =>
58
- fs.existsSync(path.join(outputDir, file)),
59
- )
60
-
61
- if (!cssSourceFile) {
62
- throw new Error(`CSS output file was not generated for icon set "${iconName}"`)
63
- }
64
-
65
- const cssPath = path.join(outputDir, cssSourceFile)
66
- const minifiedCssPath = path.join(outputDir, `${path.parse(cssSourceFile).name}.minify.css`)
67
- const cssContent = fs.readFileSync(cssPath, 'utf8')
68
- const normalizedCssContent = normalizeGeneratedCss(cssContent)
69
-
70
- if (normalizedCssContent !== cssContent) {
71
- fs.writeFileSync(cssPath, normalizedCssContent)
72
- }
73
-
74
- fs.writeFileSync(minifiedCssPath, minifyCss(normalizedCssContent))
75
-
76
47
  console.log(`✅ Icons generated for ${iconName}`)
77
48
  console.log(` input: ${inputDir}`)
78
49
  console.log(` output: ${outputDir}`)
79
50
 
80
- function minifyCss(css) {
81
- return css
82
- .replace(/\/\*[\s\S]*?\*\//g, '')
83
- .replace(/\s+/g, ' ')
84
- .replace(/\s*([{}:;,>+~])\s*/g, '$1')
85
- .replace(/;\}/g, '}')
86
- .trim()
87
- }
88
-
89
- function normalizeGeneratedCss(css) {
90
- return `${css
91
- .replace(/"/g, "'")
92
- .replace(/^ {4}/gm, ' ')
93
- .replace(/src: ([^\n]+),\n([^\n]+),\n([^\n]+);/, 'src:\n $1,\n $2,\n $3;')
94
- .replace(
95
- /i\[class\^='icon-'\]:before, i\[class\*=' icon-'\]:before \{/,
96
- "i[class^='icon-']:before,\ni[class*=' icon-']:before {",
97
- )
98
- .replace(/\n{3,}/g, '\n\n')
99
- .trim()}\n`
100
- }
101
-
102
51
  function parseArgs(rawArgs) {
103
52
  const parsedOptions = {
104
53
  name: null,
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', // remove useless stroke and fill attributes Yes
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('&', '&amp;')
315
+ .replaceAll('"', '&quot;')
316
+ .replaceAll('<', '&lt;')
317
+ .replaceAll('>', '&gt;')
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
+ }
@@ -46,6 +46,7 @@ async function main() {
46
46
  options: {
47
47
  help: { short: 'h', type: 'boolean' },
48
48
  path: { multiple: true, short: 'p', type: 'string' },
49
+ verbose: { short: 'v', type: 'boolean' },
49
50
  },
50
51
  })
51
52
 
@@ -75,7 +76,9 @@ async function main() {
75
76
  return
76
77
  }
77
78
 
78
- const { default: svgFixer } = await import('oslllo-svg-fixer')
79
+ const { convertSvg } = await import('./convert.js')
80
+ let changed = 0
81
+ let failed = 0
79
82
 
80
83
  console.log(`Fixing ${svgFiles.length} SVG file(s) in place`)
81
84
 
@@ -83,34 +86,61 @@ async function main() {
83
86
  const relativePath = path.relative(process.cwd(), source)
84
87
 
85
88
  try {
86
- const fixed = await svgFixer.fixString(await readFile(source))
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
+ }
87
103
  await writeFile(source, fixed)
104
+ changed += 1
88
105
  } catch (error) {
89
- throw new Error(`${relativePath}: ${error.message}`, { cause: error })
106
+ failed += 1
107
+ console.error(`[${index + 1}/${svgFiles.length}] failed: ${relativePath}: ${error.message}`)
108
+ continue
90
109
  }
91
110
 
92
111
  console.log(`[${index + 1}/${svgFiles.length}] ${relativePath}`)
93
112
  }
94
113
 
95
- console.log(`Fixed ${svgFiles.length} SVG file(s)`)
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
+ }
96
120
  }
97
121
 
98
122
  function printHelp() {
99
123
  console.log(`brick svg-fix <path...>
100
124
 
101
125
  Usage:
126
+ brick svg-fix ./icons/logo.svg
102
127
  brick svg-fix ./icons
103
128
  brick svg-fix ./icons ./assets/icons ./logo.svg
104
129
  brick svg-fix --path ./icons --path ./assets/icons
105
130
 
106
131
  Options:
107
132
  -p, --path Directory or SVG file; may be repeated
133
+ -v, --verbose Also print applied auto-fixes
108
134
  -h, --help Show this help
109
135
 
110
136
  Notes:
111
- Recursively converts SVG strokes to fills using oslllo-svg-fixer
137
+ Converts only elements with a visible stroke into filled outline paths
112
138
  Paths are resolved from the current working directory
113
139
  SVG files are overwritten in place; no output directory is needed
114
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
115
145
  Other files and symbolic links discovered inside directories are skipped`)
116
146
  }