@open-pencil/cli 0.7.0 → 0.9.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 +5 -3
- package/src/app-client.ts +56 -0
- package/src/commands/analyze/clusters.ts +23 -82
- package/src/commands/analyze/colors.ts +28 -152
- package/src/commands/analyze/spacing.ts +18 -49
- package/src/commands/analyze/typography.ts +23 -65
- package/src/commands/eval.ts +27 -13
- package/src/commands/export.ts +114 -56
- package/src/commands/find.ts +22 -39
- package/src/commands/info.ts +18 -30
- package/src/commands/node.ts +44 -54
- package/src/commands/pages.ts +16 -25
- package/src/commands/query.ts +90 -0
- package/src/commands/tree.ts +30 -34
- package/src/commands/variables.ts +19 -51
- package/src/format.ts +2 -2
- package/src/headless.ts +2 -2
- package/src/index.ts +3 -1
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-pencil/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"openpencil": "./src/index.ts"
|
|
8
8
|
},
|
|
9
|
-
"files": [
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
10
12
|
"repository": {
|
|
11
13
|
"type": "git",
|
|
12
14
|
"url": "git+https://github.com/open-pencil/open-pencil.git",
|
|
@@ -17,7 +19,7 @@
|
|
|
17
19
|
"provenance": true
|
|
18
20
|
},
|
|
19
21
|
"dependencies": {
|
|
20
|
-
"@open-pencil/core": "
|
|
22
|
+
"@open-pencil/core": "^0.9.0",
|
|
21
23
|
"agentfmt": "^0.1.3",
|
|
22
24
|
"canvaskit-wasm": "^0.40.0",
|
|
23
25
|
"citty": "^0.1.6"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AUTOMATION_HTTP_PORT } from '@open-pencil/core'
|
|
2
|
+
|
|
3
|
+
const HEALTH_URL = `http://127.0.0.1:${AUTOMATION_HTTP_PORT}/health`
|
|
4
|
+
const RPC_URL = `http://127.0.0.1:${AUTOMATION_HTTP_PORT}/rpc`
|
|
5
|
+
|
|
6
|
+
let cachedToken: string | null = null
|
|
7
|
+
|
|
8
|
+
export async function getAppToken(): Promise<string> {
|
|
9
|
+
if (cachedToken) return cachedToken
|
|
10
|
+
const res = await fetch(HEALTH_URL).catch(() => null)
|
|
11
|
+
if (!res || !res.ok) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Could not connect to OpenPencil app on localhost:${AUTOMATION_HTTP_PORT}.\n` +
|
|
14
|
+
'Is the app running? Start it with: bun run tauri dev'
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
const data = (await res.json()) as { status: string; token?: string }
|
|
18
|
+
if (data.status !== 'ok' || !data.token) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'OpenPencil app is running but no document is open.\n' +
|
|
21
|
+
'Open a document in the app, or provide a .fig file path.'
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
cachedToken = data.token
|
|
25
|
+
return cachedToken
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function rpc<T = unknown>(command: string, args: unknown = {}): Promise<T> {
|
|
29
|
+
const token = await getAppToken()
|
|
30
|
+
const res = await fetch(RPC_URL, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
Authorization: `Bearer ${token}`
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify({ command, args })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` }))) as { error?: string; ok?: boolean }
|
|
41
|
+
throw new Error(body.error ?? `RPC failed: HTTP ${res.status}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const body = (await res.json()) as { ok?: boolean; result?: T; error?: string }
|
|
45
|
+
if (body.ok === false) throw new Error(body.error ?? 'RPC failed')
|
|
46
|
+
return body.result as T
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isAppMode(file?: string): boolean {
|
|
50
|
+
return !file
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function requireFile(file?: string): string {
|
|
54
|
+
if (!file) throw new Error('File path is required for headless mode')
|
|
55
|
+
return file
|
|
56
|
+
}
|
|
@@ -1,43 +1,15 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { loadDocument } from '../../headless'
|
|
4
|
+
import { isAppMode, requireFile, rpc } from '../../app-client'
|
|
4
5
|
import { bold, fmtList, fmtSummary } from '../../format'
|
|
5
|
-
import
|
|
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
|
-
}
|
|
6
|
+
import { executeRpcCommand } from '@open-pencil/core'
|
|
20
7
|
|
|
21
|
-
|
|
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
|
-
}
|
|
8
|
+
import type { AnalyzeClustersResult } from '@open-pencil/core'
|
|
37
9
|
|
|
38
|
-
function calcConfidence(nodes:
|
|
10
|
+
function calcConfidence(nodes: Array<{ width: number; height: number; childCount: number }>): number {
|
|
39
11
|
if (nodes.length < 2) return 100
|
|
40
|
-
const base = nodes[0]
|
|
12
|
+
const base = nodes[0]
|
|
41
13
|
let score = 0
|
|
42
14
|
for (const node of nodes.slice(1)) {
|
|
43
15
|
const sizeDiff = Math.abs(node.width - base.width) + Math.abs(node.height - base.height)
|
|
@@ -52,7 +24,7 @@ function calcConfidence(nodes: ClusterNode[]): number {
|
|
|
52
24
|
|
|
53
25
|
function formatSignature(sig: string): string {
|
|
54
26
|
const [typeSize, children] = sig.split('|')
|
|
55
|
-
const type = typeSize
|
|
27
|
+
const type = typeSize.split(':')[0]
|
|
56
28
|
if (!type) return sig
|
|
57
29
|
const typeName = type.charAt(0) + type.slice(1).toLowerCase()
|
|
58
30
|
if (!children) return typeName
|
|
@@ -67,63 +39,31 @@ function formatSignature(sig: string): string {
|
|
|
67
39
|
return `${typeName} > [${childParts.join(', ')}]`
|
|
68
40
|
}
|
|
69
41
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 }
|
|
42
|
+
async function getData(file: string | undefined, args: { limit?: string; 'min-size'?: string; 'min-count'?: string }): Promise<AnalyzeClustersResult> {
|
|
43
|
+
const rpcArgs = { limit: Number(args.limit ?? 20), minSize: Number(args['min-size'] ?? 30), minCount: Number(args['min-count'] ?? 2) }
|
|
44
|
+
if (isAppMode(file)) return rpc<AnalyzeClustersResult>('analyze_clusters', rpcArgs)
|
|
45
|
+
const graph = await loadDocument(requireFile(file))
|
|
46
|
+
return executeRpcCommand(graph, 'analyze_clusters', rpcArgs) as AnalyzeClustersResult
|
|
103
47
|
}
|
|
104
48
|
|
|
105
49
|
export default defineCommand({
|
|
106
50
|
meta: { description: 'Find repeated design patterns (potential components)' },
|
|
107
51
|
args: {
|
|
108
|
-
file: { type: 'positional', description: '.fig file path', required:
|
|
52
|
+
file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
|
|
109
53
|
limit: { type: 'string', description: 'Max clusters to show', default: '20' },
|
|
110
54
|
'min-size': { type: 'string', description: 'Min node size in px', default: '30' },
|
|
111
55
|
'min-count': { type: 'string', description: 'Min instances to form cluster', default: '2' },
|
|
112
56
|
json: { type: 'boolean', description: 'Output as JSON' }
|
|
113
57
|
},
|
|
114
58
|
async run({ args }) {
|
|
115
|
-
const
|
|
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)
|
|
59
|
+
const data = await getData(args.file, args)
|
|
120
60
|
|
|
121
61
|
if (args.json) {
|
|
122
|
-
console.log(JSON.stringify(
|
|
62
|
+
console.log(JSON.stringify(data, null, 2))
|
|
123
63
|
return
|
|
124
64
|
}
|
|
125
65
|
|
|
126
|
-
if (clusters.length === 0) {
|
|
66
|
+
if (data.clusters.length === 0) {
|
|
127
67
|
console.log('No repeated patterns found.')
|
|
128
68
|
return
|
|
129
69
|
}
|
|
@@ -132,8 +72,8 @@ export default defineCommand({
|
|
|
132
72
|
console.log(bold(' Repeated patterns'))
|
|
133
73
|
console.log('')
|
|
134
74
|
|
|
135
|
-
const items = clusters.
|
|
136
|
-
const first = c.nodes[0]
|
|
75
|
+
const items = data.clusters.map((c) => {
|
|
76
|
+
const first = c.nodes[0]
|
|
137
77
|
const confidence = calcConfidence(c.nodes)
|
|
138
78
|
|
|
139
79
|
const widths = c.nodes.map((n) => n.width)
|
|
@@ -160,12 +100,13 @@ export default defineCommand({
|
|
|
160
100
|
|
|
161
101
|
console.log(fmtList(items, { numbered: true }))
|
|
162
102
|
|
|
163
|
-
const clusteredNodes = clusters.reduce((sum, c) => sum + c.nodes.length, 0)
|
|
103
|
+
const clusteredNodes = data.clusters.reduce((sum, c) => sum + c.nodes.length, 0)
|
|
164
104
|
console.log('')
|
|
165
|
-
console.log(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
105
|
+
console.log(fmtSummary({
|
|
106
|
+
clusters: data.clusters.length,
|
|
107
|
+
'total nodes': data.totalNodes,
|
|
108
|
+
clustered: clusteredNodes
|
|
109
|
+
}))
|
|
169
110
|
console.log('')
|
|
170
111
|
}
|
|
171
112
|
})
|
|
@@ -1,164 +1,43 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { loadDocument } from '../../headless'
|
|
4
|
+
import { isAppMode, requireFile, rpc } from '../../app-client'
|
|
4
5
|
import { bold, fmtHistogram, fmtList, fmtSummary } from '../../format'
|
|
5
|
-
import
|
|
6
|
+
import { executeRpcCommand } from '@open-pencil/core'
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
}
|
|
8
|
+
import type { AnalyzeColorsResult } from '@open-pencil/core'
|
|
73
9
|
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 }
|
|
10
|
+
async function getData(file: string | undefined, args: { threshold?: string; similar?: boolean }): Promise<AnalyzeColorsResult> {
|
|
11
|
+
const rpcArgs = { threshold: Number(args.threshold ?? 15), similar: args.similar }
|
|
12
|
+
if (isAppMode(file)) return rpc<AnalyzeColorsResult>('analyze_colors', rpcArgs)
|
|
13
|
+
const graph = await loadDocument(requireFile(file))
|
|
14
|
+
return executeRpcCommand(graph, 'analyze_colors', rpcArgs) as AnalyzeColorsResult
|
|
129
15
|
}
|
|
130
16
|
|
|
131
17
|
export default defineCommand({
|
|
132
18
|
meta: { description: 'Analyze color palette usage' },
|
|
133
19
|
args: {
|
|
134
|
-
file: { type: 'positional', description: '.fig file path', required:
|
|
20
|
+
file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
|
|
135
21
|
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
|
-
},
|
|
22
|
+
threshold: { type: 'string', description: 'Distance threshold for clustering similar colors (0–50)', default: '15' },
|
|
141
23
|
similar: { type: 'boolean', description: 'Show similar color clusters' },
|
|
142
24
|
json: { type: 'boolean', description: 'Output as JSON' }
|
|
143
25
|
},
|
|
144
26
|
async run({ args }) {
|
|
145
|
-
const
|
|
27
|
+
const data = await getData(args.file, args)
|
|
146
28
|
const limit = Number(args.limit)
|
|
147
|
-
const threshold = Number(args.threshold)
|
|
148
|
-
const { colors, totalNodes } = collectColors(graph)
|
|
149
29
|
|
|
150
30
|
if (args.json) {
|
|
151
|
-
|
|
152
|
-
console.log(JSON.stringify({ colors, totalNodes, clusters }, null, 2))
|
|
31
|
+
console.log(JSON.stringify(data, null, 2))
|
|
153
32
|
return
|
|
154
33
|
}
|
|
155
34
|
|
|
156
|
-
if (colors.length === 0) {
|
|
35
|
+
if (data.colors.length === 0) {
|
|
157
36
|
console.log('No colors found.')
|
|
158
37
|
return
|
|
159
38
|
}
|
|
160
39
|
|
|
161
|
-
const sorted = colors.
|
|
40
|
+
const sorted = data.colors.slice(0, limit)
|
|
162
41
|
|
|
163
42
|
console.log('')
|
|
164
43
|
console.log(bold(' Colors by usage'))
|
|
@@ -173,29 +52,26 @@ export default defineCommand({
|
|
|
173
52
|
)
|
|
174
53
|
)
|
|
175
54
|
|
|
176
|
-
const hardcoded = colors.filter((c) => !c.variableName)
|
|
177
|
-
const fromVars = colors.filter((c) => c.variableName)
|
|
55
|
+
const hardcoded = data.colors.filter((c) => !c.variableName)
|
|
56
|
+
const fromVars = data.colors.filter((c) => c.variableName)
|
|
178
57
|
|
|
179
58
|
console.log('')
|
|
180
59
|
console.log(
|
|
181
|
-
fmtSummary({ 'unique colors': colors.length, 'from variables': fromVars.length, hardcoded: hardcoded.length })
|
|
60
|
+
fmtSummary({ 'unique colors': data.colors.length, 'from variables': fromVars.length, hardcoded: hardcoded.length })
|
|
182
61
|
)
|
|
183
62
|
|
|
184
|
-
if (args.similar) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
details: { suggest: cluster.suggestedHex, total: `${cluster.totalCount}×` }
|
|
195
|
-
}))
|
|
196
|
-
)
|
|
63
|
+
if (args.similar && data.clusters.length > 0) {
|
|
64
|
+
console.log('')
|
|
65
|
+
console.log(bold(' Similar colors (consider merging)'))
|
|
66
|
+
console.log('')
|
|
67
|
+
console.log(
|
|
68
|
+
fmtList(
|
|
69
|
+
data.clusters.slice(0, 10).map((cluster) => ({
|
|
70
|
+
header: cluster.colors.map((c) => c.hex).join(', '),
|
|
71
|
+
details: { suggest: cluster.suggestedHex, total: `${cluster.totalCount}×` }
|
|
72
|
+
}))
|
|
197
73
|
)
|
|
198
|
-
|
|
74
|
+
)
|
|
199
75
|
}
|
|
200
76
|
|
|
201
77
|
console.log('')
|
|
@@ -1,73 +1,42 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { loadDocument } from '../../headless'
|
|
4
|
+
import { isAppMode, requireFile, rpc } from '../../app-client'
|
|
4
5
|
import { bold, kv, fmtHistogram, fmtSummary } from '../../format'
|
|
5
|
-
import
|
|
6
|
+
import { executeRpcCommand } from '@open-pencil/core'
|
|
6
7
|
|
|
7
|
-
|
|
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)
|
|
8
|
+
import type { AnalyzeSpacingResult } from '@open-pencil/core'
|
|
42
9
|
|
|
43
|
-
|
|
10
|
+
async function getData(file?: string): Promise<AnalyzeSpacingResult> {
|
|
11
|
+
if (isAppMode(file)) return rpc<AnalyzeSpacingResult>('analyze_spacing')
|
|
12
|
+
const graph = await loadDocument(requireFile(file))
|
|
13
|
+
return executeRpcCommand(graph, 'analyze_spacing', undefined) as AnalyzeSpacingResult
|
|
44
14
|
}
|
|
45
15
|
|
|
46
16
|
export default defineCommand({
|
|
47
17
|
meta: { description: 'Analyze spacing values (gap, padding)' },
|
|
48
18
|
args: {
|
|
49
|
-
file: { type: 'positional', description: '.fig file path', required:
|
|
19
|
+
file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
|
|
50
20
|
grid: { type: 'string', description: 'Base grid size to check against', default: '8' },
|
|
51
21
|
json: { type: 'boolean', description: 'Output as JSON' }
|
|
52
22
|
},
|
|
53
23
|
async run({ args }) {
|
|
54
|
-
const
|
|
24
|
+
const data = await getData(args.file)
|
|
55
25
|
const gridSize = Number(args.grid)
|
|
56
|
-
const { gaps, paddings, totalNodes } = collectSpacing(graph)
|
|
57
26
|
|
|
58
27
|
if (args.json) {
|
|
59
|
-
console.log(JSON.stringify(
|
|
28
|
+
console.log(JSON.stringify(data, null, 2))
|
|
60
29
|
return
|
|
61
30
|
}
|
|
62
31
|
|
|
63
32
|
console.log('')
|
|
64
33
|
|
|
65
|
-
if (gaps.length > 0) {
|
|
34
|
+
if (data.gaps.length > 0) {
|
|
66
35
|
console.log(bold(' Gap values'))
|
|
67
36
|
console.log('')
|
|
68
37
|
console.log(
|
|
69
38
|
fmtHistogram(
|
|
70
|
-
gaps.slice(0, 15).map((g) => ({
|
|
39
|
+
data.gaps.slice(0, 15).map((g) => ({
|
|
71
40
|
label: `${String(g.value).padStart(4)}px`,
|
|
72
41
|
value: g.count,
|
|
73
42
|
suffix: g.value % gridSize !== 0 ? '⚠' : undefined
|
|
@@ -77,12 +46,12 @@ export default defineCommand({
|
|
|
77
46
|
console.log('')
|
|
78
47
|
}
|
|
79
48
|
|
|
80
|
-
if (paddings.length > 0) {
|
|
49
|
+
if (data.paddings.length > 0) {
|
|
81
50
|
console.log(bold(' Padding values'))
|
|
82
51
|
console.log('')
|
|
83
52
|
console.log(
|
|
84
53
|
fmtHistogram(
|
|
85
|
-
paddings.slice(0, 15).map((p) => ({
|
|
54
|
+
data.paddings.slice(0, 15).map((p) => ({
|
|
86
55
|
label: `${String(p.value).padStart(4)}px`,
|
|
87
56
|
value: p.count,
|
|
88
57
|
suffix: p.value % gridSize !== 0 ? '⚠' : undefined
|
|
@@ -92,16 +61,16 @@ export default defineCommand({
|
|
|
92
61
|
console.log('')
|
|
93
62
|
}
|
|
94
63
|
|
|
95
|
-
if (gaps.length === 0 && paddings.length === 0) {
|
|
64
|
+
if (data.gaps.length === 0 && data.paddings.length === 0) {
|
|
96
65
|
console.log('No auto-layout nodes with spacing found.')
|
|
97
66
|
console.log('')
|
|
98
67
|
return
|
|
99
68
|
}
|
|
100
69
|
|
|
101
|
-
console.log(fmtSummary({ 'gap values': gaps.length, 'padding values': paddings.length }))
|
|
70
|
+
console.log(fmtSummary({ 'gap values': data.gaps.length, 'padding values': data.paddings.length }))
|
|
102
71
|
|
|
103
|
-
const offGridGaps = gaps.filter((g) => g.value % gridSize !== 0)
|
|
104
|
-
const offGridPaddings = paddings.filter((p) => p.value % gridSize !== 0)
|
|
72
|
+
const offGridGaps = data.gaps.filter((g) => g.value % gridSize !== 0)
|
|
73
|
+
const offGridPaddings = data.paddings.filter((p) => p.value % gridSize !== 0)
|
|
105
74
|
|
|
106
75
|
if (offGridGaps.length > 0 || offGridPaddings.length > 0) {
|
|
107
76
|
console.log('')
|