@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/src/format.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ok,
|
|
3
|
+
fail,
|
|
4
|
+
dim,
|
|
5
|
+
bold,
|
|
6
|
+
cyan,
|
|
7
|
+
box as fmtBox,
|
|
8
|
+
entity,
|
|
9
|
+
kv,
|
|
10
|
+
tree as fmtTree,
|
|
11
|
+
list as fmtList,
|
|
12
|
+
node as fmtNode,
|
|
13
|
+
histogram as fmtHistogram,
|
|
14
|
+
summary as fmtSummary
|
|
15
|
+
} from 'agentfmt'
|
|
16
|
+
import type { TreeNode, ListItem, NodeData } from 'agentfmt'
|
|
17
|
+
import type { SceneNode, SceneGraph } from '@open-pencil/core'
|
|
18
|
+
|
|
19
|
+
export { ok, fail, dim, bold, cyan, entity, kv, fmtTree, fmtList, fmtNode, fmtHistogram, fmtSummary }
|
|
20
|
+
|
|
21
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
22
|
+
FRAME: 'frame',
|
|
23
|
+
RECTANGLE: 'rect',
|
|
24
|
+
ROUNDED_RECTANGLE: 'rounded-rect',
|
|
25
|
+
ELLIPSE: 'ellipse',
|
|
26
|
+
TEXT: 'text',
|
|
27
|
+
COMPONENT: 'component',
|
|
28
|
+
COMPONENT_SET: 'component-set',
|
|
29
|
+
INSTANCE: 'instance',
|
|
30
|
+
GROUP: 'group',
|
|
31
|
+
VECTOR: 'vector',
|
|
32
|
+
LINE: 'line',
|
|
33
|
+
POLYGON: 'polygon',
|
|
34
|
+
STAR: 'star',
|
|
35
|
+
BOOLEAN_OPERATION: 'boolean',
|
|
36
|
+
SECTION: 'section',
|
|
37
|
+
CANVAS: 'page'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatType(type: string): string {
|
|
41
|
+
return TYPE_LABELS[type] ?? type.toLowerCase()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatBox(node: SceneNode): string {
|
|
45
|
+
return fmtBox(Math.round(node.width), Math.round(node.height), Math.round(node.x), Math.round(node.y))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatFill(node: SceneNode): string | null {
|
|
49
|
+
if (!node.fills.length) return null
|
|
50
|
+
const solid = node.fills.find((f) => f.type === 'SOLID' && f.visible)
|
|
51
|
+
if (!solid || solid.type !== 'SOLID') return null
|
|
52
|
+
const { r, g, b } = solid.color
|
|
53
|
+
const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
|
|
54
|
+
return solid.opacity < 1 ? `${hex} ${Math.round(solid.opacity * 100)}%` : hex
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatStroke(node: SceneNode): string | null {
|
|
58
|
+
if (!node.strokes.length) return null
|
|
59
|
+
const s = node.strokes[0]!
|
|
60
|
+
const { r, g, b } = s.color
|
|
61
|
+
const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
|
|
62
|
+
return `${hex} ${s.weight}px`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function nodeToData(node: SceneNode): NodeData {
|
|
66
|
+
return {
|
|
67
|
+
type: formatType(node.type),
|
|
68
|
+
name: node.name,
|
|
69
|
+
id: node.id,
|
|
70
|
+
width: Math.round(node.width),
|
|
71
|
+
height: Math.round(node.height),
|
|
72
|
+
x: Math.round(node.x),
|
|
73
|
+
y: Math.round(node.y)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function nodeDetails(node: SceneNode): Record<string, unknown> {
|
|
78
|
+
const details: Record<string, unknown> = {}
|
|
79
|
+
|
|
80
|
+
const fill = formatFill(node)
|
|
81
|
+
if (fill) details.fill = fill
|
|
82
|
+
|
|
83
|
+
const stroke = formatStroke(node)
|
|
84
|
+
if (stroke) details.stroke = stroke
|
|
85
|
+
|
|
86
|
+
if (node.cornerRadius) details.radius = `${node.cornerRadius}px`
|
|
87
|
+
|
|
88
|
+
if (node.effects.length > 0) {
|
|
89
|
+
details.effects = node.effects
|
|
90
|
+
.map((e) => {
|
|
91
|
+
if (e.type === 'DROP_SHADOW') return `shadow(${e.radius}px)`
|
|
92
|
+
if (e.type === 'INNER_SHADOW') return `inner-shadow(${e.radius}px)`
|
|
93
|
+
if (e.type === 'LAYER_BLUR') return `blur(${e.radius}px)`
|
|
94
|
+
if (e.type === 'BACKGROUND_BLUR') return `backdrop-blur(${e.radius}px)`
|
|
95
|
+
return e.type.toLowerCase()
|
|
96
|
+
})
|
|
97
|
+
.join(', ')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (node.rotation) details.rotate = `${Math.round(node.rotation)}°`
|
|
101
|
+
if (node.opacity < 1) details.opacity = node.opacity
|
|
102
|
+
if (node.blendMode !== 'PASS_THROUGH' && node.blendMode !== 'NORMAL') {
|
|
103
|
+
details.blend = node.blendMode.toLowerCase().replace(/_/g, '-')
|
|
104
|
+
}
|
|
105
|
+
if (node.clipsContent) details.overflow = 'hidden'
|
|
106
|
+
if (!node.visible) details.visible = false
|
|
107
|
+
if (node.locked) details.locked = true
|
|
108
|
+
|
|
109
|
+
if (node.fontFamily) {
|
|
110
|
+
details.font = `${node.fontSize}px ${node.fontFamily}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (node.layoutMode !== 'NONE') {
|
|
114
|
+
let layout = node.layoutMode.toLowerCase()
|
|
115
|
+
if (node.layoutWrap === 'WRAP') layout += ' wrap'
|
|
116
|
+
if (node.itemSpacing) layout += ` gap=${node.itemSpacing}`
|
|
117
|
+
details.layout = layout
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (node.componentId) details.componentId = node.componentId
|
|
121
|
+
|
|
122
|
+
return details
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function nodeToTreeNode(graph: SceneGraph, node: SceneNode, maxDepth: number, depth = 0): TreeNode {
|
|
126
|
+
const treeNode: TreeNode = {
|
|
127
|
+
header: entity(formatType(node.type), node.name, node.id),
|
|
128
|
+
details: nodeDetails(node)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (depth < maxDepth && node.childIds.length > 0) {
|
|
132
|
+
treeNode.children = node.childIds
|
|
133
|
+
.map((id) => graph.getNode(id))
|
|
134
|
+
.filter((n): n is SceneNode => n !== undefined)
|
|
135
|
+
.map((child) => nodeToTreeNode(graph, child, maxDepth, depth + 1))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return treeNode
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function nodeToListItem(node: SceneNode): ListItem {
|
|
142
|
+
const details = nodeDetails(node)
|
|
143
|
+
return {
|
|
144
|
+
header: entity(formatType(node.type), node.name, node.id),
|
|
145
|
+
details: Object.keys(details).length > 0 ? details : undefined
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatNodeSingle(node: SceneNode): string {
|
|
150
|
+
return fmtNode(nodeToData(node), nodeDetails(node))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function printError(error: unknown): void {
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
155
|
+
console.error(fail(message))
|
|
156
|
+
}
|
package/src/headless.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import CanvasKitInit from 'canvaskit-wasm/full'
|
|
2
|
+
import type { CanvasKit } from 'canvaskit-wasm'
|
|
3
|
+
import {
|
|
4
|
+
parseFigFile,
|
|
5
|
+
SceneGraph,
|
|
6
|
+
SkiaRenderer,
|
|
7
|
+
computeAllLayouts,
|
|
8
|
+
loadFont,
|
|
9
|
+
renderNodesToImage,
|
|
10
|
+
renderThumbnail
|
|
11
|
+
} from '@open-pencil/core'
|
|
12
|
+
import type { ExportFormat } from '@open-pencil/core'
|
|
13
|
+
|
|
14
|
+
let ck: CanvasKit | null = null
|
|
15
|
+
|
|
16
|
+
export async function initCanvasKit(): Promise<CanvasKit> {
|
|
17
|
+
if (ck) return ck
|
|
18
|
+
const ckPath = import.meta.resolve('canvaskit-wasm/full')
|
|
19
|
+
const binDir = new URL('.', ckPath).pathname
|
|
20
|
+
ck = await CanvasKitInit({
|
|
21
|
+
locateFile: (file) => binDir + file
|
|
22
|
+
})
|
|
23
|
+
return ck
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function loadDocument(filePath: string): Promise<SceneGraph> {
|
|
27
|
+
const data = await Bun.file(filePath).arrayBuffer()
|
|
28
|
+
const graph = await parseFigFile(data)
|
|
29
|
+
computeAllLayouts(graph)
|
|
30
|
+
return graph
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function loadFonts(graph: SceneGraph): Promise<void> {
|
|
34
|
+
const families = new Set<string>()
|
|
35
|
+
for (const node of graph.getAllNodes()) {
|
|
36
|
+
if (node.fontFamily) families.add(node.fontFamily)
|
|
37
|
+
}
|
|
38
|
+
for (const family of families) {
|
|
39
|
+
await loadFont(family)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createRenderer(ckInstance: CanvasKit, width: number, height: number): SkiaRenderer {
|
|
44
|
+
const surface = ckInstance.MakeSurface(width, height)!
|
|
45
|
+
const renderer = new SkiaRenderer(ckInstance, surface)
|
|
46
|
+
renderer.viewportWidth = width
|
|
47
|
+
renderer.viewportHeight = height
|
|
48
|
+
renderer.dpr = 1
|
|
49
|
+
return renderer
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function exportNodes(
|
|
53
|
+
graph: SceneGraph,
|
|
54
|
+
pageId: string,
|
|
55
|
+
nodeIds: string[],
|
|
56
|
+
options: { scale?: number; format?: ExportFormat; quality?: number }
|
|
57
|
+
): Promise<Uint8Array | null> {
|
|
58
|
+
const ckInstance = await initCanvasKit()
|
|
59
|
+
const renderer = createRenderer(ckInstance, 1, 1)
|
|
60
|
+
return renderNodesToImage(ckInstance, renderer, graph, pageId, nodeIds, {
|
|
61
|
+
scale: options.scale ?? 1,
|
|
62
|
+
format: options.format ?? 'PNG',
|
|
63
|
+
quality: options.quality
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function exportThumbnail(
|
|
68
|
+
graph: SceneGraph,
|
|
69
|
+
pageId: string,
|
|
70
|
+
width: number,
|
|
71
|
+
height: number
|
|
72
|
+
): Promise<Uint8Array | null> {
|
|
73
|
+
const ckInstance = await initCanvasKit()
|
|
74
|
+
const renderer = createRenderer(ckInstance, width, height)
|
|
75
|
+
return renderThumbnail(ckInstance, renderer, graph, pageId, width, height)
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { defineCommand, runMain } from 'citty'
|
|
3
|
+
|
|
4
|
+
import analyze from './commands/analyze'
|
|
5
|
+
import evalCmd from './commands/eval'
|
|
6
|
+
import exportCmd from './commands/export'
|
|
7
|
+
import find from './commands/find'
|
|
8
|
+
import info from './commands/info'
|
|
9
|
+
import node from './commands/node'
|
|
10
|
+
import pages from './commands/pages'
|
|
11
|
+
import tree from './commands/tree'
|
|
12
|
+
import variables from './commands/variables'
|
|
13
|
+
|
|
14
|
+
const main = defineCommand({
|
|
15
|
+
meta: {
|
|
16
|
+
name: 'open-pencil',
|
|
17
|
+
description: 'OpenPencil CLI — inspect, export, and lint .fig design files',
|
|
18
|
+
version: '0.1.0'
|
|
19
|
+
},
|
|
20
|
+
subCommands: {
|
|
21
|
+
analyze,
|
|
22
|
+
eval: evalCmd,
|
|
23
|
+
export: exportCmd,
|
|
24
|
+
find,
|
|
25
|
+
info,
|
|
26
|
+
node,
|
|
27
|
+
pages,
|
|
28
|
+
tree,
|
|
29
|
+
variables
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
runMain(main)
|