@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/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
+ }
@@ -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)