@open-pencil/cli 0.8.0 → 0.10.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 +4 -4
- package/src/commands/analyze/clusters.ts +8 -7
- package/src/commands/analyze/typography.ts +1 -1
- package/src/commands/eval.ts +15 -15
- package/src/commands/export.ts +99 -112
- package/src/commands/find.ts +1 -1
- package/src/commands/node.ts +2 -2
- package/src/commands/pages.ts +1 -1
- package/src/commands/query.ts +90 -0
- package/src/commands/variables.ts +2 -2
- package/src/format.ts +2 -2
- package/src/headless.ts +8 -48
- package/src/index.ts +3 -1
- package/LICENSE +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-pencil/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
"provenance": true
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"@open-pencil/core": "^0.10.0",
|
|
22
23
|
"agentfmt": "^0.1.3",
|
|
23
24
|
"canvaskit-wasm": "^0.40.0",
|
|
24
|
-
"citty": "^0.1.6"
|
|
25
|
-
"@open-pencil/core": "0.8.0"
|
|
25
|
+
"citty": "^0.1.6"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/bun": "^1.2.9"
|
|
29
29
|
}
|
|
30
|
-
}
|
|
30
|
+
}
|
|
@@ -9,7 +9,7 @@ import type { AnalyzeClustersResult } from '@open-pencil/core'
|
|
|
9
9
|
|
|
10
10
|
function calcConfidence(nodes: Array<{ width: number; height: number; childCount: number }>): number {
|
|
11
11
|
if (nodes.length < 2) return 100
|
|
12
|
-
const base = nodes[0]
|
|
12
|
+
const base = nodes[0]
|
|
13
13
|
let score = 0
|
|
14
14
|
for (const node of nodes.slice(1)) {
|
|
15
15
|
const sizeDiff = Math.abs(node.width - base.width) + Math.abs(node.height - base.height)
|
|
@@ -24,7 +24,7 @@ function calcConfidence(nodes: Array<{ width: number; height: number; childCount
|
|
|
24
24
|
|
|
25
25
|
function formatSignature(sig: string): string {
|
|
26
26
|
const [typeSize, children] = sig.split('|')
|
|
27
|
-
const type = typeSize
|
|
27
|
+
const type = typeSize.split(':')[0]
|
|
28
28
|
if (!type) return sig
|
|
29
29
|
const typeName = type.charAt(0) + type.slice(1).toLowerCase()
|
|
30
30
|
if (!children) return typeName
|
|
@@ -73,7 +73,7 @@ export default defineCommand({
|
|
|
73
73
|
console.log('')
|
|
74
74
|
|
|
75
75
|
const items = data.clusters.map((c) => {
|
|
76
|
-
const first = c.nodes[0]
|
|
76
|
+
const first = c.nodes[0]
|
|
77
77
|
const confidence = calcConfidence(c.nodes)
|
|
78
78
|
|
|
79
79
|
const widths = c.nodes.map((n) => n.width)
|
|
@@ -102,10 +102,11 @@ export default defineCommand({
|
|
|
102
102
|
|
|
103
103
|
const clusteredNodes = data.clusters.reduce((sum, c) => sum + c.nodes.length, 0)
|
|
104
104
|
console.log('')
|
|
105
|
-
console.log(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
console.log(fmtSummary({
|
|
106
|
+
clusters: data.clusters.length,
|
|
107
|
+
'total nodes': data.totalNodes,
|
|
108
|
+
clustered: clusteredNodes
|
|
109
|
+
}))
|
|
109
110
|
console.log('')
|
|
110
111
|
}
|
|
111
112
|
})
|
|
@@ -91,7 +91,7 @@ export default defineCommand({
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
console.log('')
|
|
94
|
-
console.log(fmtSummary({ 'unique styles': data.styles.length
|
|
94
|
+
console.log(fmtSummary({ 'unique styles': data.styles.length, 'text nodes': data.totalTextNodes }))
|
|
95
95
|
console.log('')
|
|
96
96
|
}
|
|
97
97
|
})
|
package/src/commands/eval.ts
CHANGED
|
@@ -6,9 +6,17 @@ import { loadDocument } from '../headless'
|
|
|
6
6
|
import { isAppMode, requireFile, rpc } from '../app-client'
|
|
7
7
|
import { printError } from '../format'
|
|
8
8
|
|
|
9
|
+
function printResult(value: unknown, json: boolean) {
|
|
10
|
+
if (json || !process.stdout.isTTY) {
|
|
11
|
+
console.log(JSON.stringify(value, null, 2))
|
|
12
|
+
} else {
|
|
13
|
+
console.log(value)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
function serializeResult(value: unknown): unknown {
|
|
10
18
|
if (value === undefined || value === null) return value
|
|
11
|
-
if (typeof value === 'object' &&
|
|
19
|
+
if (typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') {
|
|
12
20
|
return value.toJSON()
|
|
13
21
|
}
|
|
14
22
|
if (Array.isArray(value)) return value.map(serializeResult)
|
|
@@ -22,7 +30,7 @@ export default defineCommand({
|
|
|
22
30
|
code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' },
|
|
23
31
|
stdin: { type: 'boolean', description: 'Read code from stdin' },
|
|
24
32
|
write: { type: 'boolean', alias: 'w', description: 'Write changes back to the input file' },
|
|
25
|
-
output: { type: 'string', alias: 'o', description: 'Write to a different file' },
|
|
33
|
+
output: { type: 'string', alias: 'o', description: 'Write to a different file', required: false },
|
|
26
34
|
json: { type: 'boolean', description: 'Output as JSON' },
|
|
27
35
|
quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' },
|
|
28
36
|
},
|
|
@@ -41,13 +49,9 @@ export default defineCommand({
|
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
if (isAppMode(args.file)) {
|
|
44
|
-
const result = await rpc
|
|
52
|
+
const result = await rpc('eval', { code })
|
|
45
53
|
if (!args.quiet && result !== undefined && result !== null) {
|
|
46
|
-
|
|
47
|
-
console.log(JSON.stringify(result, null, 2))
|
|
48
|
-
} else {
|
|
49
|
-
console.log(result)
|
|
50
|
-
}
|
|
54
|
+
printResult(result, !!args.json)
|
|
51
55
|
}
|
|
52
56
|
return
|
|
53
57
|
}
|
|
@@ -56,6 +60,7 @@ export default defineCommand({
|
|
|
56
60
|
const graph = await loadDocument(file)
|
|
57
61
|
const figma = new FigmaAPI(graph)
|
|
58
62
|
|
|
63
|
+
// eslint-disable-next-line no-empty-function -- needed to get AsyncFunction constructor
|
|
59
64
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
60
65
|
const wrappedCode = code.trim().startsWith('return')
|
|
61
66
|
? code
|
|
@@ -71,17 +76,12 @@ export default defineCommand({
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
if (!args.quiet && result !== undefined) {
|
|
74
|
-
|
|
75
|
-
if (args.json || !process.stdout.isTTY) {
|
|
76
|
-
console.log(JSON.stringify(serialized, null, 2))
|
|
77
|
-
} else {
|
|
78
|
-
console.log(serialized)
|
|
79
|
-
}
|
|
79
|
+
printResult(serializeResult(result), !!args.json)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
if (args.write || args.output) {
|
|
83
83
|
const { exportFigFile } = await import('@open-pencil/core')
|
|
84
|
-
const outPath = args.output
|
|
84
|
+
const outPath = args.output ? args.output : file
|
|
85
85
|
const data = await exportFigFile(graph)
|
|
86
86
|
await Bun.write(outPath, new Uint8Array(data))
|
|
87
87
|
if (!args.quiet) {
|
package/src/commands/export.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename, extname, resolve } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import { renderNodesToSVG, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
|
|
5
5
|
|
|
6
|
-
import { loadDocument,
|
|
6
|
+
import { loadDocument, exportNodes, exportThumbnail } from '../headless'
|
|
7
7
|
import { isAppMode, requireFile, rpc } from '../app-client'
|
|
8
8
|
import { ok, printError } from '../format'
|
|
9
9
|
import type { ExportFormat, JSXFormat } from '@open-pencil/core'
|
|
@@ -12,16 +12,108 @@ const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
|
|
|
12
12
|
const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX']
|
|
13
13
|
const JSX_STYLES = ['openpencil', 'tailwind']
|
|
14
14
|
|
|
15
|
+
interface ExportArgs {
|
|
16
|
+
file?: string
|
|
17
|
+
output?: string
|
|
18
|
+
format: string
|
|
19
|
+
scale: string
|
|
20
|
+
quality?: string
|
|
21
|
+
page?: string
|
|
22
|
+
node?: string
|
|
23
|
+
style: string
|
|
24
|
+
thumbnail?: boolean
|
|
25
|
+
width: string
|
|
26
|
+
height: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function writeAndLog(path: string, content: string | Uint8Array) {
|
|
30
|
+
await Bun.write(path, content)
|
|
31
|
+
const size = typeof content === 'string' ? content.length : content.length
|
|
32
|
+
console.log(ok(`Exported ${path} (${(size / 1024).toFixed(1)} KB)`))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function exportViaApp(format: string, args: ExportArgs) {
|
|
36
|
+
if (format === 'SVG') {
|
|
37
|
+
const result = await rpc<{ svg: string }>('tool', { name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } })
|
|
38
|
+
if (!result.svg) { printError('Nothing to export.'); process.exit(1) }
|
|
39
|
+
await writeAndLog(resolve(args.output ?? 'export.svg'), result.svg)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (format === 'JSX') {
|
|
44
|
+
const result = await rpc<{ jsx: string }>('export_jsx', { nodeIds: args.node ? [args.node] : undefined, style: args.style })
|
|
45
|
+
if (!result.jsx) { printError('Nothing to export.'); process.exit(1) }
|
|
46
|
+
await writeAndLog(resolve(args.output ?? 'export.jsx'), result.jsx)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = await rpc<{ base64: string }>('export', {
|
|
51
|
+
nodeIds: args.node ? [args.node] : undefined,
|
|
52
|
+
scale: Number(args.scale),
|
|
53
|
+
format: format.toLowerCase()
|
|
54
|
+
})
|
|
55
|
+
const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0))
|
|
56
|
+
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
|
|
57
|
+
await writeAndLog(resolve(args.output ?? `export.${ext}`), data)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function exportFromFile(format: string, args: ExportArgs) {
|
|
61
|
+
const file = requireFile(args.file)
|
|
62
|
+
const graph = await loadDocument(file)
|
|
63
|
+
|
|
64
|
+
const pages = graph.getPages()
|
|
65
|
+
const page = args.page ? pages.find((p) => p.name === args.page) : pages[0]
|
|
66
|
+
if (!page) { printError(`Page "${args.page}" not found.`); process.exit(1) }
|
|
67
|
+
|
|
68
|
+
const defaultName = basename(file, extname(file))
|
|
69
|
+
|
|
70
|
+
if (format === 'JSX') {
|
|
71
|
+
const nodeIds = args.node ? [args.node] : page.childIds
|
|
72
|
+
const jsxStr = nodeIds.length === 1
|
|
73
|
+
? sceneNodeToJSX(nodeIds[0], graph, args.style as JSXFormat)
|
|
74
|
+
: selectionToJSX(nodeIds, graph, args.style as JSXFormat)
|
|
75
|
+
if (!jsxStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
|
|
76
|
+
await writeAndLog(resolve(args.output ?? `${defaultName}.jsx`), jsxStr)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
|
|
81
|
+
const output = resolve(args.output ?? `${defaultName}.${ext}`)
|
|
82
|
+
|
|
83
|
+
if (format === 'SVG') {
|
|
84
|
+
const nodeIds = args.node ? [args.node] : page.childIds
|
|
85
|
+
const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
|
|
86
|
+
if (!svgStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
|
|
87
|
+
await writeAndLog(output, svgStr)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let data: Uint8Array | null
|
|
92
|
+
if (args.thumbnail) {
|
|
93
|
+
data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
|
|
94
|
+
} else {
|
|
95
|
+
const nodeIds = args.node ? [args.node] : page.childIds
|
|
96
|
+
data = await exportNodes(graph, page.id, nodeIds, {
|
|
97
|
+
scale: Number(args.scale),
|
|
98
|
+
format: format as ExportFormat,
|
|
99
|
+
quality: args.quality ? Number(args.quality) : undefined
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!data) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
|
|
104
|
+
await writeAndLog(output, data)
|
|
105
|
+
}
|
|
106
|
+
|
|
15
107
|
export default defineCommand({
|
|
16
108
|
meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' },
|
|
17
109
|
args: {
|
|
18
110
|
file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
|
|
19
|
-
output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)' },
|
|
111
|
+
output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)', required: false },
|
|
20
112
|
format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg, jsx (default: png)', default: 'png' },
|
|
21
113
|
scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
|
|
22
|
-
quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' },
|
|
23
|
-
page: { type: 'string', description: 'Page name (default: first page)' },
|
|
24
|
-
node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' },
|
|
114
|
+
quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)', required: false },
|
|
115
|
+
page: { type: 'string', description: 'Page name (default: first page)', required: false },
|
|
116
|
+
node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)', required: false },
|
|
25
117
|
style: { type: 'string', description: 'JSX style: openpencil, tailwind (default: openpencil)', default: 'openpencil' },
|
|
26
118
|
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
|
|
27
119
|
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
|
|
@@ -40,114 +132,9 @@ export default defineCommand({
|
|
|
40
132
|
}
|
|
41
133
|
|
|
42
134
|
if (isAppMode(args.file)) {
|
|
43
|
-
|
|
44
|
-
const result = await rpc<{ svg: string }>('tool', { name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } })
|
|
45
|
-
if (!result.svg) {
|
|
46
|
-
printError('Nothing to export.')
|
|
47
|
-
process.exit(1)
|
|
48
|
-
}
|
|
49
|
-
const output = resolve(args.output ?? 'export.svg')
|
|
50
|
-
await Bun.write(output, result.svg)
|
|
51
|
-
console.log(ok(`Exported ${output} (${(result.svg.length / 1024).toFixed(1)} KB)`))
|
|
52
|
-
return
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (format === 'JSX') {
|
|
56
|
-
const result = await rpc<{ jsx: string }>('export_jsx', {
|
|
57
|
-
nodeIds: args.node ? [args.node] : undefined,
|
|
58
|
-
style: args.style
|
|
59
|
-
})
|
|
60
|
-
if (!result.jsx) {
|
|
61
|
-
printError('Nothing to export.')
|
|
62
|
-
process.exit(1)
|
|
63
|
-
}
|
|
64
|
-
const output = resolve(args.output ?? 'export.jsx')
|
|
65
|
-
await Bun.write(output, result.jsx)
|
|
66
|
-
console.log(ok(`Exported ${output} (${(result.jsx.length / 1024).toFixed(1)} KB)`))
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const result = await rpc<{ base64: string }>('export', {
|
|
71
|
-
nodeIds: args.node ? [args.node] : undefined,
|
|
72
|
-
scale: Number(args.scale),
|
|
73
|
-
format: format.toLowerCase()
|
|
74
|
-
})
|
|
75
|
-
const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0))
|
|
76
|
-
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
|
|
77
|
-
const output = resolve(args.output ?? `export.${ext}`)
|
|
78
|
-
await Bun.write(output, data)
|
|
79
|
-
console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const file = requireFile(args.file)
|
|
84
|
-
const graph = await loadDocument(file)
|
|
85
|
-
await loadFonts(graph)
|
|
86
|
-
|
|
87
|
-
const pages = graph.getPages()
|
|
88
|
-
const page = args.page
|
|
89
|
-
? pages.find((p) => p.name === args.page)
|
|
90
|
-
: pages[0]
|
|
91
|
-
|
|
92
|
-
if (!page) {
|
|
93
|
-
printError(`Page "${args.page}" not found.`)
|
|
94
|
-
process.exit(1)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const defaultName = basename(file, extname(file))
|
|
98
|
-
|
|
99
|
-
if (format === 'JSX') {
|
|
100
|
-
const jsxFormat = args.style as JSXFormat
|
|
101
|
-
const nodeIds = args.node ? [args.node] : page.childIds
|
|
102
|
-
const jsxStr = nodeIds.length === 1
|
|
103
|
-
? sceneNodeToJSX(nodeIds[0], graph, jsxFormat)
|
|
104
|
-
: selectionToJSX(nodeIds, graph, jsxFormat)
|
|
105
|
-
|
|
106
|
-
if (!jsxStr) {
|
|
107
|
-
printError('Nothing to export (empty page or no visible nodes).')
|
|
108
|
-
process.exit(1)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const output = resolve(args.output ?? `${defaultName}.jsx`)
|
|
112
|
-
await Bun.write(output, jsxStr)
|
|
113
|
-
console.log(ok(`Exported ${output} (${(jsxStr.length / 1024).toFixed(1)} KB)`))
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
|
|
118
|
-
const output = resolve(args.output ?? `${defaultName}.${ext}`)
|
|
119
|
-
|
|
120
|
-
if (format === 'SVG') {
|
|
121
|
-
const nodeIds = args.node ? [args.node] : page.childIds
|
|
122
|
-
const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
|
|
123
|
-
if (!svgStr) {
|
|
124
|
-
printError('Nothing to export (empty page or no visible nodes).')
|
|
125
|
-
process.exit(1)
|
|
126
|
-
}
|
|
127
|
-
await Bun.write(output, svgStr)
|
|
128
|
-
console.log(ok(`Exported ${output} (${(svgStr.length / 1024).toFixed(1)} KB)`))
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
let data: Uint8Array | null
|
|
133
|
-
|
|
134
|
-
if (args.thumbnail) {
|
|
135
|
-
data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
|
|
135
|
+
await exportViaApp(format, args)
|
|
136
136
|
} else {
|
|
137
|
-
|
|
138
|
-
data = await exportNodes(graph, page.id, nodeIds, {
|
|
139
|
-
scale: Number(args.scale),
|
|
140
|
-
format,
|
|
141
|
-
quality: args.quality ? Number(args.quality) : undefined
|
|
142
|
-
})
|
|
137
|
+
await exportFromFile(format, args)
|
|
143
138
|
}
|
|
144
|
-
|
|
145
|
-
if (!data) {
|
|
146
|
-
printError('Nothing to export (empty page or no visible nodes).')
|
|
147
|
-
process.exit(1)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
await Bun.write(output, data)
|
|
151
|
-
console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
|
|
152
139
|
}
|
|
153
140
|
})
|
package/src/commands/find.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
|
|
|
2
2
|
|
|
3
3
|
import { loadDocument } from '../headless'
|
|
4
4
|
import { isAppMode, requireFile, rpc } from '../app-client'
|
|
5
|
-
import { fmtList,
|
|
5
|
+
import { fmtList, bold, entity, formatType } from '../format'
|
|
6
6
|
import { executeRpcCommand } from '@open-pencil/core'
|
|
7
7
|
|
|
8
8
|
import type { FindNodeResult } from '@open-pencil/core'
|
package/src/commands/node.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { isAppMode, requireFile, rpc } from '../app-client'
|
|
|
5
5
|
import { fmtNode, printError, formatType } from '../format'
|
|
6
6
|
import { executeRpcCommand, colorToHex } from '@open-pencil/core'
|
|
7
7
|
|
|
8
|
-
import type { NodeResult } from '@open-pencil/core'
|
|
8
|
+
import type { Color, NodeResult } from '@open-pencil/core'
|
|
9
9
|
|
|
10
10
|
async function getData(file: string | undefined, id: string): Promise<NodeResult | { error: string }> {
|
|
11
11
|
if (isAppMode(file)) return rpc<NodeResult>('node', { id })
|
|
@@ -47,7 +47,7 @@ export default defineCommand({
|
|
|
47
47
|
if (data.parent) details.parent = `${data.parent.name} (${data.parent.id})`
|
|
48
48
|
if (data.text) details.text = data.text
|
|
49
49
|
if (data.fills.length > 0) {
|
|
50
|
-
const solid = (data.fills as Array<{ type: string; visible: boolean; color:
|
|
50
|
+
const solid = (data.fills as Array<{ type: string; visible: boolean; color: Color; opacity: number }>)
|
|
51
51
|
.find((f) => f.type === 'SOLID' && f.visible)
|
|
52
52
|
if (solid) {
|
|
53
53
|
const hex = colorToHex(solid.color)
|
package/src/commands/pages.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
|
|
|
2
2
|
|
|
3
3
|
import { loadDocument } from '../headless'
|
|
4
4
|
import { isAppMode, requireFile, rpc } from '../app-client'
|
|
5
|
-
import { bold, fmtList, entity
|
|
5
|
+
import { bold, fmtList, entity } from '../format'
|
|
6
6
|
|
|
7
7
|
import type { PageItem } from '@open-pencil/core'
|
|
8
8
|
import { executeRpcCommand } from '@open-pencil/core'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { loadDocument } from '../headless'
|
|
4
|
+
import { isAppMode, requireFile, rpc } from '../app-client'
|
|
5
|
+
import { fmtList, printError, bold, entity, formatType } from '../format'
|
|
6
|
+
import { executeRpcCommand } from '@open-pencil/core'
|
|
7
|
+
|
|
8
|
+
import type { QueryNodeResult } from '@open-pencil/core'
|
|
9
|
+
|
|
10
|
+
async function getData(
|
|
11
|
+
file: string | undefined,
|
|
12
|
+
args: { selector: string; page?: string; limit?: string }
|
|
13
|
+
): Promise<QueryNodeResult[] | { error: string }> {
|
|
14
|
+
const rpcArgs = {
|
|
15
|
+
selector: args.selector,
|
|
16
|
+
page: args.page,
|
|
17
|
+
limit: args.limit ? Number(args.limit) : undefined
|
|
18
|
+
}
|
|
19
|
+
if (isAppMode(file)) return rpc<QueryNodeResult[]>('query', rpcArgs)
|
|
20
|
+
const graph = await loadDocument(requireFile(file))
|
|
21
|
+
return await executeRpcCommand(graph, 'query', rpcArgs) as QueryNodeResult[] | { error: string }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default defineCommand({
|
|
25
|
+
meta: {
|
|
26
|
+
description: `Query nodes using XPath selectors
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
open-pencil query file.fig "//FRAME" # All frames
|
|
30
|
+
open-pencil query file.fig "//FRAME[@width < 300]" # Frames narrower than 300px
|
|
31
|
+
open-pencil query file.fig "//COMPONENT[starts-with(@name, 'Button')]" # Components starting with Button
|
|
32
|
+
open-pencil query file.fig "//SECTION/FRAME" # Direct frame children of sections
|
|
33
|
+
open-pencil query file.fig "//SECTION//TEXT" # All text inside sections
|
|
34
|
+
open-pencil query file.fig "//*[@cornerRadius > 0]" # Any node with corner radius`
|
|
35
|
+
},
|
|
36
|
+
args: {
|
|
37
|
+
file: {
|
|
38
|
+
type: 'positional',
|
|
39
|
+
description: '.fig file path (omit to connect to running app)',
|
|
40
|
+
required: false
|
|
41
|
+
},
|
|
42
|
+
selector: {
|
|
43
|
+
type: 'positional',
|
|
44
|
+
description:
|
|
45
|
+
'XPath selector (e.g., //FRAME[@width < 300], //TEXT[contains(@name, "Label")])',
|
|
46
|
+
required: true
|
|
47
|
+
},
|
|
48
|
+
page: { type: 'string', description: 'Page name (default: all pages)' },
|
|
49
|
+
limit: { type: 'string', description: 'Max results (default: 1000)', default: '1000' },
|
|
50
|
+
json: { type: 'boolean', description: 'Output as JSON' }
|
|
51
|
+
},
|
|
52
|
+
async run({ args }) {
|
|
53
|
+
const results = await getData(args.file, {
|
|
54
|
+
selector: args.selector,
|
|
55
|
+
page: args.page,
|
|
56
|
+
limit: args.limit
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if ('error' in results) {
|
|
60
|
+
printError(results.error)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (args.json) {
|
|
65
|
+
console.log(JSON.stringify(results, null, 2))
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (results.length === 0) {
|
|
70
|
+
console.log('No nodes found.')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log('')
|
|
75
|
+
console.log(bold(` Found ${results.length} node${results.length > 1 ? 's' : ''}`))
|
|
76
|
+
console.log('')
|
|
77
|
+
console.log(
|
|
78
|
+
fmtList(
|
|
79
|
+
results.map((n) => ({
|
|
80
|
+
header: entity(
|
|
81
|
+
formatType(n.type),
|
|
82
|
+
`${n.name} ${n.width}×${n.height}`,
|
|
83
|
+
n.id
|
|
84
|
+
)
|
|
85
|
+
}))
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
console.log('')
|
|
89
|
+
}
|
|
90
|
+
})
|
|
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
|
|
|
2
2
|
|
|
3
3
|
import { loadDocument } from '../headless'
|
|
4
4
|
import { isAppMode, requireFile, rpc } from '../app-client'
|
|
5
|
-
import { bold, fmtList, fmtSummary } from '../format'
|
|
5
|
+
import { bold, entity, fmtList, fmtSummary } from '../format'
|
|
6
6
|
import { executeRpcCommand } from '@open-pencil/core'
|
|
7
7
|
|
|
8
8
|
import type { VariablesResult } from '@open-pencil/core'
|
|
@@ -38,7 +38,7 @@ export default defineCommand({
|
|
|
38
38
|
console.log('')
|
|
39
39
|
|
|
40
40
|
for (const coll of data.collections) {
|
|
41
|
-
console.log(bold(
|
|
41
|
+
console.log(bold(entity(coll.name, coll.modes.join(', '))))
|
|
42
42
|
console.log('')
|
|
43
43
|
console.log(
|
|
44
44
|
fmtList(
|
package/src/format.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function formatBox(node: SceneNode): string {
|
|
|
48
48
|
function formatFill(node: SceneNode): string | null {
|
|
49
49
|
if (!node.fills.length) return null
|
|
50
50
|
const solid = node.fills.find((f) => f.type === 'SOLID' && f.visible)
|
|
51
|
-
if (!solid
|
|
51
|
+
if (!solid?.color) return null
|
|
52
52
|
const { r, g, b } = solid.color
|
|
53
53
|
const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
|
|
54
54
|
return solid.opacity < 1 ? `${hex} ${Math.round(solid.opacity * 100)}%` : hex
|
|
@@ -56,7 +56,7 @@ function formatFill(node: SceneNode): string | null {
|
|
|
56
56
|
|
|
57
57
|
function formatStroke(node: SceneNode): string | null {
|
|
58
58
|
if (!node.strokes.length) return null
|
|
59
|
-
const s = node.strokes[0]
|
|
59
|
+
const s = node.strokes[0]
|
|
60
60
|
const { r, g, b } = s.color
|
|
61
61
|
const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
|
|
62
62
|
return `${hex} ${s.weight}px`
|
package/src/headless.ts
CHANGED
|
@@ -1,27 +1,14 @@
|
|
|
1
|
-
import CanvasKitInit from 'canvaskit-wasm/full'
|
|
2
|
-
import type { CanvasKit } from 'canvaskit-wasm'
|
|
3
1
|
import {
|
|
4
2
|
parseFigFile,
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
initCanvasKit,
|
|
4
|
+
type SceneGraph,
|
|
5
|
+
type ExportFormat,
|
|
7
6
|
computeAllLayouts,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
renderThumbnail
|
|
7
|
+
headlessRenderNodes,
|
|
8
|
+
headlessRenderThumbnail
|
|
11
9
|
} from '@open-pencil/core'
|
|
12
|
-
import type { ExportFormat } from '@open-pencil/core'
|
|
13
10
|
|
|
14
|
-
|
|
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
|
-
}
|
|
11
|
+
export { initCanvasKit }
|
|
25
12
|
|
|
26
13
|
export async function loadDocument(filePath: string): Promise<SceneGraph> {
|
|
27
14
|
const data = await Bun.file(filePath).arrayBuffer()
|
|
@@ -30,38 +17,13 @@ export async function loadDocument(filePath: string): Promise<SceneGraph> {
|
|
|
30
17
|
return graph
|
|
31
18
|
}
|
|
32
19
|
|
|
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
20
|
export async function exportNodes(
|
|
53
21
|
graph: SceneGraph,
|
|
54
22
|
pageId: string,
|
|
55
23
|
nodeIds: string[],
|
|
56
24
|
options: { scale?: number; format?: ExportFormat; quality?: number }
|
|
57
25
|
): Promise<Uint8Array | null> {
|
|
58
|
-
|
|
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
|
-
})
|
|
26
|
+
return headlessRenderNodes(graph, pageId, nodeIds, options)
|
|
65
27
|
}
|
|
66
28
|
|
|
67
29
|
export async function exportThumbnail(
|
|
@@ -70,7 +32,5 @@ export async function exportThumbnail(
|
|
|
70
32
|
width: number,
|
|
71
33
|
height: number
|
|
72
34
|
): Promise<Uint8Array | null> {
|
|
73
|
-
|
|
74
|
-
const renderer = createRenderer(ckInstance, width, height)
|
|
75
|
-
return renderThumbnail(ckInstance, renderer, graph, pageId, width, height)
|
|
35
|
+
return headlessRenderThumbnail(graph, pageId, width, height)
|
|
76
36
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import evalCmd from './commands/eval'
|
|
|
6
6
|
import exportCmd from './commands/export'
|
|
7
7
|
import find from './commands/find'
|
|
8
8
|
import info from './commands/info'
|
|
9
|
+
import query from './commands/query'
|
|
9
10
|
import node from './commands/node'
|
|
10
11
|
import pages from './commands/pages'
|
|
11
12
|
import tree from './commands/tree'
|
|
@@ -25,6 +26,7 @@ const main = defineCommand({
|
|
|
25
26
|
export: exportCmd,
|
|
26
27
|
find,
|
|
27
28
|
info,
|
|
29
|
+
query,
|
|
28
30
|
node,
|
|
29
31
|
pages,
|
|
30
32
|
tree,
|
|
@@ -32,4 +34,4 @@ const main = defineCommand({
|
|
|
32
34
|
}
|
|
33
35
|
})
|
|
34
36
|
|
|
35
|
-
runMain(main)
|
|
37
|
+
void runMain(main)
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Danila Poyarkov
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|