@open-pencil/cli 0.1.0
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/package.json +27 -0
- package/src/commands/analyze/clusters.ts +171 -0
- package/src/commands/analyze/colors.ts +203 -0
- package/src/commands/analyze/index.ts +16 -0
- package/src/commands/analyze/spacing.ts +119 -0
- package/src/commands/analyze/typography.ts +139 -0
- package/src/commands/eval.ts +78 -0
- package/src/commands/export.ts +67 -0
- package/src/commands/find.ts +69 -0
- package/src/commands/info.ts +58 -0
- package/src/commands/node.ts +83 -0
- package/src/commands/pages.ts +53 -0
- package/src/commands/tree.ts +63 -0
- package/src/commands/variables.ts +95 -0
- package/src/format.ts +156 -0
- package/src/headless.ts +76 -0
- package/src/index.ts +33 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-pencil/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"open-pencil": "./src/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"files": ["src"],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/open-pencil/open-pencil.git",
|
|
12
|
+
"directory": "packages/cli"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public",
|
|
16
|
+
"provenance": true
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@open-pencil/core": "workspace:*",
|
|
20
|
+
"agentfmt": "^0.1.3",
|
|
21
|
+
"canvaskit-wasm": "^0.40.0",
|
|
22
|
+
"citty": "^0.1.6"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/bun": "^1.2.9"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../../headless'
|
|
4
|
+
import { bold, fmtList, fmtSummary } from '../../format'
|
|
5
|
+
import type { SceneNode, SceneGraph } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
interface ClusterNode {
|
|
8
|
+
id: string
|
|
9
|
+
name: string
|
|
10
|
+
type: string
|
|
11
|
+
width: number
|
|
12
|
+
height: number
|
|
13
|
+
childCount: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Cluster {
|
|
17
|
+
signature: string
|
|
18
|
+
nodes: ClusterNode[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildSignature(graph: SceneGraph, node: SceneNode): string {
|
|
22
|
+
const childTypes = new Map<string, number>()
|
|
23
|
+
for (const childId of node.childIds) {
|
|
24
|
+
const child = graph.getNode(childId)
|
|
25
|
+
if (!child) continue
|
|
26
|
+
childTypes.set(child.type, (childTypes.get(child.type) ?? 0) + 1)
|
|
27
|
+
}
|
|
28
|
+
const childPart = [...childTypes.entries()]
|
|
29
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
30
|
+
.map(([t, c]) => `${t}:${c}`)
|
|
31
|
+
.join(',')
|
|
32
|
+
|
|
33
|
+
const w = Math.round(node.width / 10) * 10
|
|
34
|
+
const h = Math.round(node.height / 10) * 10
|
|
35
|
+
return `${node.type}:${w}x${h}|${childPart}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function calcConfidence(nodes: ClusterNode[]): number {
|
|
39
|
+
if (nodes.length < 2) return 100
|
|
40
|
+
const base = nodes[0]!
|
|
41
|
+
let score = 0
|
|
42
|
+
for (const node of nodes.slice(1)) {
|
|
43
|
+
const sizeDiff = Math.abs(node.width - base.width) + Math.abs(node.height - base.height)
|
|
44
|
+
const childDiff = Math.abs(node.childCount - base.childCount)
|
|
45
|
+
if (sizeDiff <= 4 && childDiff === 0) score++
|
|
46
|
+
else if (sizeDiff <= 10 && childDiff <= 1) score += 0.8
|
|
47
|
+
else if (sizeDiff <= 20 && childDiff <= 2) score += 0.6
|
|
48
|
+
else score += 0.4
|
|
49
|
+
}
|
|
50
|
+
return Math.round((score / (nodes.length - 1)) * 100)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatSignature(sig: string): string {
|
|
54
|
+
const [typeSize, children] = sig.split('|')
|
|
55
|
+
const type = typeSize?.split(':')[0]
|
|
56
|
+
if (!type) return sig
|
|
57
|
+
const typeName = type.charAt(0) + type.slice(1).toLowerCase()
|
|
58
|
+
if (!children) return typeName
|
|
59
|
+
|
|
60
|
+
const childParts = children.split(',').map((c) => {
|
|
61
|
+
const [t, count] = c.split(':')
|
|
62
|
+
if (!t) return ''
|
|
63
|
+
const name = t.charAt(0) + t.slice(1).toLowerCase()
|
|
64
|
+
return Number(count) > 1 ? `${name}×${count}` : name
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return `${typeName} > [${childParts.join(', ')}]`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findClusters(
|
|
71
|
+
graph: SceneGraph,
|
|
72
|
+
minSize: number,
|
|
73
|
+
minCount: number
|
|
74
|
+
): { clusters: Cluster[]; totalNodes: number } {
|
|
75
|
+
const sigMap = new Map<string, ClusterNode[]>()
|
|
76
|
+
let totalNodes = 0
|
|
77
|
+
|
|
78
|
+
for (const node of graph.getAllNodes()) {
|
|
79
|
+
if (node.type === 'CANVAS') continue
|
|
80
|
+
totalNodes++
|
|
81
|
+
if (node.width < minSize || node.height < minSize) continue
|
|
82
|
+
if (node.childIds.length === 0) continue
|
|
83
|
+
|
|
84
|
+
const sig = buildSignature(graph, node)
|
|
85
|
+
const arr = sigMap.get(sig) ?? []
|
|
86
|
+
arr.push({
|
|
87
|
+
id: node.id,
|
|
88
|
+
name: node.name,
|
|
89
|
+
type: node.type,
|
|
90
|
+
width: Math.round(node.width),
|
|
91
|
+
height: Math.round(node.height),
|
|
92
|
+
childCount: node.childIds.length
|
|
93
|
+
})
|
|
94
|
+
sigMap.set(sig, arr)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const clusters = [...sigMap.entries()]
|
|
98
|
+
.filter(([, nodes]) => nodes.length >= minCount)
|
|
99
|
+
.map(([signature, nodes]) => ({ signature, nodes }))
|
|
100
|
+
.sort((a, b) => b.nodes.length - a.nodes.length)
|
|
101
|
+
|
|
102
|
+
return { clusters, totalNodes }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default defineCommand({
|
|
106
|
+
meta: { description: 'Find repeated design patterns (potential components)' },
|
|
107
|
+
args: {
|
|
108
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
109
|
+
limit: { type: 'string', description: 'Max clusters to show', default: '20' },
|
|
110
|
+
'min-size': { type: 'string', description: 'Min node size in px', default: '30' },
|
|
111
|
+
'min-count': { type: 'string', description: 'Min instances to form cluster', default: '2' },
|
|
112
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
113
|
+
},
|
|
114
|
+
async run({ args }) {
|
|
115
|
+
const graph = await loadDocument(args.file)
|
|
116
|
+
const limit = Number(args.limit)
|
|
117
|
+
const minSize = Number(args['min-size'])
|
|
118
|
+
const minCount = Number(args['min-count'])
|
|
119
|
+
const { clusters, totalNodes } = findClusters(graph, minSize, minCount)
|
|
120
|
+
|
|
121
|
+
if (args.json) {
|
|
122
|
+
console.log(JSON.stringify({ clusters: clusters.slice(0, limit), totalNodes }, null, 2))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (clusters.length === 0) {
|
|
127
|
+
console.log('No repeated patterns found.')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log('')
|
|
132
|
+
console.log(bold(' Repeated patterns'))
|
|
133
|
+
console.log('')
|
|
134
|
+
|
|
135
|
+
const items = clusters.slice(0, limit).map((c) => {
|
|
136
|
+
const first = c.nodes[0]!
|
|
137
|
+
const confidence = calcConfidence(c.nodes)
|
|
138
|
+
|
|
139
|
+
const widths = c.nodes.map((n) => n.width)
|
|
140
|
+
const heights = c.nodes.map((n) => n.height)
|
|
141
|
+
const wRange = Math.max(...widths) - Math.min(...widths)
|
|
142
|
+
const hRange = Math.max(...heights) - Math.min(...heights)
|
|
143
|
+
const avgW = Math.round(widths.reduce((a, b) => a + b, 0) / widths.length)
|
|
144
|
+
const avgH = Math.round(heights.reduce((a, b) => a + b, 0) / heights.length)
|
|
145
|
+
|
|
146
|
+
const sizeStr =
|
|
147
|
+
wRange <= 4 && hRange <= 4
|
|
148
|
+
? `${avgW}×${avgH}`
|
|
149
|
+
: `${avgW}×${avgH} (±${Math.max(wRange, hRange)}px)`
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
header: `${c.nodes.length}× ${first.type.toLowerCase()} "${first.name}" (${confidence}% match)`,
|
|
153
|
+
details: {
|
|
154
|
+
size: sizeStr,
|
|
155
|
+
structure: formatSignature(c.signature),
|
|
156
|
+
examples: c.nodes.slice(0, 3).map((n) => n.id).join(', ')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
console.log(fmtList(items, { numbered: true }))
|
|
162
|
+
|
|
163
|
+
const clusteredNodes = clusters.reduce((sum, c) => sum + c.nodes.length, 0)
|
|
164
|
+
console.log('')
|
|
165
|
+
console.log(
|
|
166
|
+
fmtSummary({ clusters: clusters.length }) +
|
|
167
|
+
` from ${totalNodes} nodes (${clusteredNodes} clustered)`
|
|
168
|
+
)
|
|
169
|
+
console.log('')
|
|
170
|
+
}
|
|
171
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../../headless'
|
|
4
|
+
import { bold, fmtHistogram, fmtList, fmtSummary } from '../../format'
|
|
5
|
+
import type { SceneGraph } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
interface ColorInfo {
|
|
8
|
+
hex: string
|
|
9
|
+
count: number
|
|
10
|
+
variableName: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toHex(r: number, g: number, b: number): string {
|
|
14
|
+
return (
|
|
15
|
+
'#' +
|
|
16
|
+
[r, g, b]
|
|
17
|
+
.map((c) =>
|
|
18
|
+
Math.round(c * 255)
|
|
19
|
+
.toString(16)
|
|
20
|
+
.padStart(2, '0')
|
|
21
|
+
)
|
|
22
|
+
.join('')
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
27
|
+
const clean = hex.replace('#', '')
|
|
28
|
+
return [
|
|
29
|
+
parseInt(clean.slice(0, 2), 16),
|
|
30
|
+
parseInt(clean.slice(2, 4), 16),
|
|
31
|
+
parseInt(clean.slice(4, 6), 16)
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function colorDistance(hex1: string, hex2: string): number {
|
|
36
|
+
const [r1, g1, b1] = hexToRgb(hex1)
|
|
37
|
+
const [r2, g2, b2] = hexToRgb(hex2)
|
|
38
|
+
return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Cluster {
|
|
42
|
+
colors: ColorInfo[]
|
|
43
|
+
suggestedHex: string
|
|
44
|
+
totalCount: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clusterColors(colors: ColorInfo[], threshold: number): Cluster[] {
|
|
48
|
+
const clusters: Cluster[] = []
|
|
49
|
+
const used = new Set<string>()
|
|
50
|
+
const sorted = [...colors].sort((a, b) => b.count - a.count)
|
|
51
|
+
|
|
52
|
+
for (const color of sorted) {
|
|
53
|
+
if (used.has(color.hex)) continue
|
|
54
|
+
|
|
55
|
+
const cluster: Cluster = {
|
|
56
|
+
colors: [color],
|
|
57
|
+
suggestedHex: color.hex,
|
|
58
|
+
totalCount: color.count
|
|
59
|
+
}
|
|
60
|
+
used.add(color.hex)
|
|
61
|
+
|
|
62
|
+
for (const other of sorted) {
|
|
63
|
+
if (used.has(other.hex)) continue
|
|
64
|
+
if (colorDistance(color.hex, other.hex) <= threshold) {
|
|
65
|
+
cluster.colors.push(other)
|
|
66
|
+
cluster.totalCount += other.count
|
|
67
|
+
used.add(other.hex)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (cluster.colors.length > 1) clusters.push(cluster)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return clusters.sort((a, b) => b.colors.length - a.colors.length)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function collectColors(graph: SceneGraph): { colors: ColorInfo[]; totalNodes: number } {
|
|
78
|
+
const colorMap = new Map<string, ColorInfo>()
|
|
79
|
+
let totalNodes = 0
|
|
80
|
+
|
|
81
|
+
const addColor = (hex: string, variableName: string | null) => {
|
|
82
|
+
const existing = colorMap.get(hex)
|
|
83
|
+
if (existing) {
|
|
84
|
+
existing.count++
|
|
85
|
+
if (variableName && !existing.variableName) existing.variableName = variableName
|
|
86
|
+
} else {
|
|
87
|
+
colorMap.set(hex, { hex, count: 1, variableName })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const node of graph.getAllNodes()) {
|
|
92
|
+
if (node.type === 'CANVAS') continue
|
|
93
|
+
totalNodes++
|
|
94
|
+
|
|
95
|
+
for (const fill of node.fills) {
|
|
96
|
+
if (!fill.visible || fill.type !== 'SOLID') continue
|
|
97
|
+
const hex = toHex(fill.color.r, fill.color.g, fill.color.b)
|
|
98
|
+
addColor(hex, null)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const stroke of node.strokes) {
|
|
102
|
+
if (!stroke.visible) continue
|
|
103
|
+
const hex = toHex(stroke.color.r, stroke.color.g, stroke.color.b)
|
|
104
|
+
addColor(hex, null)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const effect of node.effects) {
|
|
108
|
+
if (!effect.visible) continue
|
|
109
|
+
const hex = toHex(effect.color.r, effect.color.g, effect.color.b)
|
|
110
|
+
addColor(hex, null)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const [field, varId] of Object.entries(node.boundVariables)) {
|
|
114
|
+
if (!field.includes('fill') && !field.includes('stroke') && !field.includes('color'))
|
|
115
|
+
continue
|
|
116
|
+
const variable = graph.variables.get(varId)
|
|
117
|
+
if (variable) {
|
|
118
|
+
const resolvedColor = graph.resolveColorVariable(varId)
|
|
119
|
+
if (resolvedColor) {
|
|
120
|
+
const hex = toHex(resolvedColor.r, resolvedColor.g, resolvedColor.b)
|
|
121
|
+
const existing = colorMap.get(hex)
|
|
122
|
+
if (existing) existing.variableName = variable.name
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { colors: [...colorMap.values()], totalNodes }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default defineCommand({
|
|
132
|
+
meta: { description: 'Analyze color palette usage' },
|
|
133
|
+
args: {
|
|
134
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
135
|
+
limit: { type: 'string', description: 'Max colors to show', default: '30' },
|
|
136
|
+
threshold: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
description: 'Distance threshold for clustering similar colors (0–50)',
|
|
139
|
+
default: '15'
|
|
140
|
+
},
|
|
141
|
+
similar: { type: 'boolean', description: 'Show similar color clusters' },
|
|
142
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
143
|
+
},
|
|
144
|
+
async run({ args }) {
|
|
145
|
+
const graph = await loadDocument(args.file)
|
|
146
|
+
const limit = Number(args.limit)
|
|
147
|
+
const threshold = Number(args.threshold)
|
|
148
|
+
const { colors, totalNodes } = collectColors(graph)
|
|
149
|
+
|
|
150
|
+
if (args.json) {
|
|
151
|
+
const clusters = args.similar ? clusterColors(colors, threshold) : []
|
|
152
|
+
console.log(JSON.stringify({ colors, totalNodes, clusters }, null, 2))
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (colors.length === 0) {
|
|
157
|
+
console.log('No colors found.')
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sorted = colors.sort((a, b) => b.count - a.count).slice(0, limit)
|
|
162
|
+
|
|
163
|
+
console.log('')
|
|
164
|
+
console.log(bold(' Colors by usage'))
|
|
165
|
+
console.log('')
|
|
166
|
+
console.log(
|
|
167
|
+
fmtHistogram(
|
|
168
|
+
sorted.map((c) => ({
|
|
169
|
+
label: c.hex,
|
|
170
|
+
value: c.count,
|
|
171
|
+
tag: c.variableName ? `$${c.variableName}` : undefined
|
|
172
|
+
}))
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const hardcoded = colors.filter((c) => !c.variableName)
|
|
177
|
+
const fromVars = colors.filter((c) => c.variableName)
|
|
178
|
+
|
|
179
|
+
console.log('')
|
|
180
|
+
console.log(
|
|
181
|
+
fmtSummary({ 'unique colors': colors.length, 'from variables': fromVars.length, hardcoded: hardcoded.length })
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if (args.similar) {
|
|
185
|
+
const clusters = clusterColors(hardcoded, threshold)
|
|
186
|
+
if (clusters.length > 0) {
|
|
187
|
+
console.log('')
|
|
188
|
+
console.log(bold(' Similar colors (consider merging)'))
|
|
189
|
+
console.log('')
|
|
190
|
+
console.log(
|
|
191
|
+
fmtList(
|
|
192
|
+
clusters.slice(0, 10).map((cluster) => ({
|
|
193
|
+
header: cluster.colors.map((c) => c.hex).join(', '),
|
|
194
|
+
details: { suggest: cluster.suggestedHex, total: `${cluster.totalCount}×` }
|
|
195
|
+
}))
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log('')
|
|
202
|
+
}
|
|
203
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import colors from './colors'
|
|
4
|
+
import typography from './typography'
|
|
5
|
+
import spacing from './spacing'
|
|
6
|
+
import clusters from './clusters'
|
|
7
|
+
|
|
8
|
+
export default defineCommand({
|
|
9
|
+
meta: { description: 'Analyze design tokens and patterns' },
|
|
10
|
+
subCommands: {
|
|
11
|
+
colors,
|
|
12
|
+
typography,
|
|
13
|
+
spacing,
|
|
14
|
+
clusters
|
|
15
|
+
}
|
|
16
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../../headless'
|
|
4
|
+
import { bold, kv, fmtHistogram, fmtSummary } from '../../format'
|
|
5
|
+
import type { SceneGraph } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
interface SpacingValue {
|
|
8
|
+
value: number
|
|
9
|
+
count: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function collectSpacing(graph: SceneGraph): {
|
|
13
|
+
gaps: SpacingValue[]
|
|
14
|
+
paddings: SpacingValue[]
|
|
15
|
+
totalNodes: number
|
|
16
|
+
} {
|
|
17
|
+
const gapMap = new Map<number, number>()
|
|
18
|
+
const paddingMap = new Map<number, number>()
|
|
19
|
+
let totalNodes = 0
|
|
20
|
+
|
|
21
|
+
for (const node of graph.getAllNodes()) {
|
|
22
|
+
if (node.type === 'CANVAS') continue
|
|
23
|
+
if (node.layoutMode === 'NONE') continue
|
|
24
|
+
totalNodes++
|
|
25
|
+
|
|
26
|
+
if (node.itemSpacing > 0) {
|
|
27
|
+
gapMap.set(node.itemSpacing, (gapMap.get(node.itemSpacing) ?? 0) + 1)
|
|
28
|
+
}
|
|
29
|
+
if (node.counterAxisSpacing > 0) {
|
|
30
|
+
gapMap.set(node.counterAxisSpacing, (gapMap.get(node.counterAxisSpacing) ?? 0) + 1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const pad of [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]) {
|
|
34
|
+
if (pad > 0) paddingMap.set(pad, (paddingMap.get(pad) ?? 0) + 1)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const toValues = (map: Map<number, number>) =>
|
|
39
|
+
[...map.entries()]
|
|
40
|
+
.map(([value, count]) => ({ value, count }))
|
|
41
|
+
.sort((a, b) => b.count - a.count)
|
|
42
|
+
|
|
43
|
+
return { gaps: toValues(gapMap), paddings: toValues(paddingMap), totalNodes }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default defineCommand({
|
|
47
|
+
meta: { description: 'Analyze spacing values (gap, padding)' },
|
|
48
|
+
args: {
|
|
49
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
50
|
+
grid: { type: 'string', description: 'Base grid size to check against', default: '8' },
|
|
51
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
52
|
+
},
|
|
53
|
+
async run({ args }) {
|
|
54
|
+
const graph = await loadDocument(args.file)
|
|
55
|
+
const gridSize = Number(args.grid)
|
|
56
|
+
const { gaps, paddings, totalNodes } = collectSpacing(graph)
|
|
57
|
+
|
|
58
|
+
if (args.json) {
|
|
59
|
+
console.log(JSON.stringify({ gaps, paddings, totalNodes }, null, 2))
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('')
|
|
64
|
+
|
|
65
|
+
if (gaps.length > 0) {
|
|
66
|
+
console.log(bold(' Gap values'))
|
|
67
|
+
console.log('')
|
|
68
|
+
console.log(
|
|
69
|
+
fmtHistogram(
|
|
70
|
+
gaps.slice(0, 15).map((g) => ({
|
|
71
|
+
label: `${String(g.value).padStart(4)}px`,
|
|
72
|
+
value: g.count,
|
|
73
|
+
suffix: g.value % gridSize !== 0 ? '⚠' : undefined
|
|
74
|
+
}))
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
console.log('')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (paddings.length > 0) {
|
|
81
|
+
console.log(bold(' Padding values'))
|
|
82
|
+
console.log('')
|
|
83
|
+
console.log(
|
|
84
|
+
fmtHistogram(
|
|
85
|
+
paddings.slice(0, 15).map((p) => ({
|
|
86
|
+
label: `${String(p.value).padStart(4)}px`,
|
|
87
|
+
value: p.count,
|
|
88
|
+
suffix: p.value % gridSize !== 0 ? '⚠' : undefined
|
|
89
|
+
}))
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
console.log('')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (gaps.length === 0 && paddings.length === 0) {
|
|
96
|
+
console.log('No auto-layout nodes with spacing found.')
|
|
97
|
+
console.log('')
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log(fmtSummary({ 'gap values': gaps.length, 'padding values': paddings.length }))
|
|
102
|
+
|
|
103
|
+
const offGridGaps = gaps.filter((g) => g.value % gridSize !== 0)
|
|
104
|
+
const offGridPaddings = paddings.filter((p) => p.value % gridSize !== 0)
|
|
105
|
+
|
|
106
|
+
if (offGridGaps.length > 0 || offGridPaddings.length > 0) {
|
|
107
|
+
console.log('')
|
|
108
|
+
console.log(bold(` ⚠ Off-grid values (not ÷${gridSize}px)`))
|
|
109
|
+
if (offGridGaps.length > 0) {
|
|
110
|
+
console.log(kv('Gaps', offGridGaps.map((g) => `${g.value}px`).join(', ')))
|
|
111
|
+
}
|
|
112
|
+
if (offGridPaddings.length > 0) {
|
|
113
|
+
console.log(kv('Paddings', offGridPaddings.map((p) => `${p.value}px`).join(', ')))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log('')
|
|
118
|
+
}
|
|
119
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../../headless'
|
|
4
|
+
import { bold, fmtHistogram, fmtSummary } from '../../format'
|
|
5
|
+
import type { SceneGraph } from '@open-pencil/core'
|
|
6
|
+
|
|
7
|
+
interface TypographyStyle {
|
|
8
|
+
family: string
|
|
9
|
+
size: number
|
|
10
|
+
weight: number
|
|
11
|
+
lineHeight: string
|
|
12
|
+
count: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function collectTypography(graph: SceneGraph): { styles: TypographyStyle[]; totalTextNodes: number } {
|
|
16
|
+
const styleMap = new Map<string, TypographyStyle>()
|
|
17
|
+
let totalTextNodes = 0
|
|
18
|
+
|
|
19
|
+
for (const node of graph.getAllNodes()) {
|
|
20
|
+
if (node.type !== 'TEXT') continue
|
|
21
|
+
totalTextNodes++
|
|
22
|
+
|
|
23
|
+
const lh = node.lineHeight === null ? 'auto' : `${node.lineHeight}px`
|
|
24
|
+
const key = `${node.fontFamily}|${node.fontSize}|${node.fontWeight}|${lh}`
|
|
25
|
+
|
|
26
|
+
const existing = styleMap.get(key)
|
|
27
|
+
if (existing) {
|
|
28
|
+
existing.count++
|
|
29
|
+
} else {
|
|
30
|
+
styleMap.set(key, {
|
|
31
|
+
family: node.fontFamily,
|
|
32
|
+
size: node.fontSize,
|
|
33
|
+
weight: node.fontWeight,
|
|
34
|
+
lineHeight: lh,
|
|
35
|
+
count: 1
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { styles: [...styleMap.values()], totalTextNodes }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function weightName(w: number): string {
|
|
44
|
+
if (w <= 100) return 'Thin'
|
|
45
|
+
if (w <= 200) return 'ExtraLight'
|
|
46
|
+
if (w <= 300) return 'Light'
|
|
47
|
+
if (w <= 400) return 'Regular'
|
|
48
|
+
if (w <= 500) return 'Medium'
|
|
49
|
+
if (w <= 600) return 'SemiBold'
|
|
50
|
+
if (w <= 700) return 'Bold'
|
|
51
|
+
if (w <= 800) return 'ExtraBold'
|
|
52
|
+
return 'Black'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default defineCommand({
|
|
56
|
+
meta: { description: 'Analyze typography usage' },
|
|
57
|
+
args: {
|
|
58
|
+
file: { type: 'positional', description: '.fig file path', required: true },
|
|
59
|
+
'group-by': {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: 'Group by: family, size, weight (default: show all styles)'
|
|
62
|
+
},
|
|
63
|
+
limit: { type: 'string', description: 'Max styles to show', default: '30' },
|
|
64
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
65
|
+
},
|
|
66
|
+
async run({ args }) {
|
|
67
|
+
const graph = await loadDocument(args.file)
|
|
68
|
+
const limit = Number(args.limit)
|
|
69
|
+
const groupBy = args['group-by']
|
|
70
|
+
const { styles, totalTextNodes } = collectTypography(graph)
|
|
71
|
+
|
|
72
|
+
if (args.json) {
|
|
73
|
+
console.log(JSON.stringify({ styles, totalTextNodes }, null, 2))
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (styles.length === 0) {
|
|
78
|
+
console.log('No text nodes found.')
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sorted = styles.sort((a, b) => b.count - a.count)
|
|
83
|
+
|
|
84
|
+
console.log('')
|
|
85
|
+
|
|
86
|
+
if (groupBy === 'family') {
|
|
87
|
+
const byFamily = new Map<string, number>()
|
|
88
|
+
for (const s of sorted) byFamily.set(s.family, (byFamily.get(s.family) ?? 0) + s.count)
|
|
89
|
+
console.log(bold(' Font families'))
|
|
90
|
+
console.log('')
|
|
91
|
+
console.log(
|
|
92
|
+
fmtHistogram(
|
|
93
|
+
[...byFamily.entries()]
|
|
94
|
+
.sort((a, b) => b[1] - a[1])
|
|
95
|
+
.map(([family, count]) => ({ label: family, value: count }))
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
} else if (groupBy === 'size') {
|
|
99
|
+
const bySize = new Map<number, number>()
|
|
100
|
+
for (const s of sorted) bySize.set(s.size, (bySize.get(s.size) ?? 0) + s.count)
|
|
101
|
+
console.log(bold(' Font sizes'))
|
|
102
|
+
console.log('')
|
|
103
|
+
console.log(
|
|
104
|
+
fmtHistogram(
|
|
105
|
+
[...bySize.entries()]
|
|
106
|
+
.sort((a, b) => a[0] - b[0])
|
|
107
|
+
.map(([size, count]) => ({ label: `${size}px`, value: count }))
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
} else if (groupBy === 'weight') {
|
|
111
|
+
const byWeight = new Map<number, number>()
|
|
112
|
+
for (const s of sorted) byWeight.set(s.weight, (byWeight.get(s.weight) ?? 0) + s.count)
|
|
113
|
+
console.log(bold(' Font weights'))
|
|
114
|
+
console.log('')
|
|
115
|
+
console.log(
|
|
116
|
+
fmtHistogram(
|
|
117
|
+
[...byWeight.entries()]
|
|
118
|
+
.sort((a, b) => b[1] - a[1])
|
|
119
|
+
.map(([weight, count]) => ({ label: `${weight} ${weightName(weight)}`, value: count }))
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
} else {
|
|
123
|
+
console.log(bold(' Typography styles'))
|
|
124
|
+
console.log('')
|
|
125
|
+
const items = sorted.slice(0, limit).map((s) => {
|
|
126
|
+
const lh = s.lineHeight !== 'auto' ? ` / ${s.lineHeight}` : ''
|
|
127
|
+
return {
|
|
128
|
+
label: `${s.family} ${s.size}px ${weightName(s.weight)}${lh}`,
|
|
129
|
+
value: s.count
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
console.log(fmtHistogram(items))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log('')
|
|
136
|
+
console.log(fmtSummary({ 'unique styles': styles.length }) + ` from ${totalTextNodes} text nodes`)
|
|
137
|
+
console.log('')
|
|
138
|
+
}
|
|
139
|
+
})
|