openpencil/packages/docs/ru/eval-command.md
2026-03-08 11:40:19 +03:00

438 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `open-pencil eval` — Figma-совместимый Plugin API для скриптинга без GUI
## Обзор
`bun open-pencil eval <file> --code '<js>'` выполняет JavaScript-код над `.fig`-файлом с глобальным объектом `figma`, совместимым с Figma. Это позволяет автоматизировать пакетные операции, запускать AI-инструменты и тесты — всё без графического интерфейса.
Объект `figma` максимально точно повторяет API плагинов Figma, поэтому существующие знания и фрагменты кода для плагинов Figma можно использовать напрямую.
```bash
# Создать фрейм, настроить автораскладку, добавить дочерние элементы
bun open-pencil eval design.fig --code '
const frame = figma.createFrame()
frame.name = "Card"
frame.resize(300, 200)
frame.layoutMode = "VERTICAL"
frame.itemSpacing = 12
frame.paddingTop = frame.paddingBottom = 16
frame.paddingLeft = frame.paddingRight = 16
frame.fills = [{ type: "SOLID", color: { r: 1, g: 1, b: 1 } }]
const title = figma.createText()
title.characters = "Hello World"
title.fontSize = 24
frame.appendChild(title)
return { id: frame.id, name: frame.name }
'
# Поиск узлов
bun open-pencil eval design.fig --code '
const buttons = figma.currentPage.findAll(n => n.type === "FRAME" && n.name.includes("Button"))
return buttons.map(b => ({ id: b.id, name: b.name, w: b.width, h: b.height }))
'
# Чтение из stdin (для многострочных скриптов / пайплайнов)
cat transform.js | bun open-pencil eval design.fig --stdin
# Сохранить изменения обратно в файл
bun open-pencil eval design.fig --code '...' --write
bun open-pencil eval design.fig --code '...' -o modified.fig
```
## Архитектура
```
┌──────────────────────────────────────────────────────┐
│ CLI: `open-pencil eval <file> --code '...'` │
│ ↓ │
│ loadDocument(file) → SceneGraph │
│ ↓ │
│ FigmaAPI(sceneGraph) → прокси-объект `figma` │
│ ↓ │
│ AsyncFunction('figma', wrappedCode)(figmaProxy) │
│ ↓ │
│ вывод результата в JSON / agentfmt │
│ опционально: saveDocument(file) при --write │
└──────────────────────────────────────────────────────┘
```
### Основные классы
| Класс | Расположение | Роль |
|-------|--------------|------|
| `FigmaAPI` | `packages/core/src/figma-api.ts` | Прокси-объект, реализующий методы `figma.*` поверх `SceneGraph` |
| `FigmaNode` | `packages/core/src/figma-api.ts` | Прокси-обёртка над `SceneNode` с доступом к свойствам в стиле Figma (`.fills`, `.resize()`, `.appendChild()` и т.д.) |
| Команда `eval` | `packages/cli/src/commands/eval.ts` | CLI-команда, загружающая документ, создающая API и выполняющая код |
### Почему в `@open-pencil/core`?
Класс `FigmaAPI` находится в core (а не в CLI), потому что:
- **AI-инструменты используют его** — инструмент `render` в панели чата выполняет JSX через тот же API
- **Тестовые скрипты** — модульные тесты могут использовать API для подготовки фикстур
- **Нет зависимостей от DOM** — работает в headless-режиме в Bun, без браузерных API
## `FigmaAPI` — поэтапная реализация
### Фаза 1: Базовый функционал (MVP для команды eval)
Покрывает ~80% реальных скриптов для плагинов:
#### Документ и страницы
| Figma API | Наша реализация | Примечания |
|-----------|-----------------|------------|
| `figma.root` | Геттер → прокси для корневого узла | `.children` возвращает прокси страниц |
| `figma.currentPage` | Геттер/сеттер → первая страница по умолчанию | Можно установить на любой прокси страницы |
| `figma.currentPage.selection` | Чтение/запись → отслеживаемый массив выделения | |
| `figma.getNodeById(id)` | `graph.getNode(id)`, обёрнутый в прокси | Синхронный, как устаревшая версия Figma |
#### Создание узлов
| Figma API | Соответствие |
|-----------|--------------|
| `figma.createFrame()` | `graph.createNode('FRAME', currentPageId)` |
| `figma.createRectangle()` | `graph.createNode('RECTANGLE', ...)` |
| `figma.createEllipse()` | `graph.createNode('ELLIPSE', ...)` |
| `figma.createText()` | `graph.createNode('TEXT', ...)` |
| `figma.createLine()` | `graph.createNode('LINE', ...)` |
| `figma.createPolygon()` | `graph.createNode('POLYGON', ...)` |
| `figma.createStar()` | `graph.createNode('STAR', ...)` |
| `figma.createComponent()` | `graph.createNode('COMPONENT', ...)` |
| `figma.createPage()` | `graph.addPage(name)` |
| `figma.createSection()` | `graph.createNode('SECTION', ...)` |
#### Свойства узлов (через прокси `FigmaNode`)
Чтение и запись на любом прокси-узле. Обращение к свойствам транслируется в поля `SceneNode`:
```ts
// Геометрия
node.x, node.y // прямое соответствие
node.width, node.height // только чтение, используйте node.resize(w, h)
node.rotation // прямое соответствие
node.resize(w, h) // обновляет width и height
node.resizeWithoutConstraints(w, h) // аналогично (движка ограничений пока нет)
// Визуальные свойства
node.fills // чтение/запись Fill[]
node.strokes // чтение/запись Stroke[]
node.effects // чтение/запись Effect[]
node.opacity // чтение/запись number
node.visible // чтение/запись boolean
node.locked // чтение/запись boolean
node.blendMode // чтение/запись BlendMode
node.clipsContent // чтение/запись boolean
// Скругление углов
node.cornerRadius // чтение/запись (number или figma.mixed)
node.topLeftRadius // чтение/запись
node.topRightRadius // чтение/запись
node.bottomLeftRadius // чтение/запись
node.bottomRightRadius // чтение/запись
node.cornerSmoothing // чтение/запись
// Идентификация
node.id // только чтение
node.name // чтение/запись
node.type // только чтение
node.parent // только чтение → FigmaNode | null
node.removed // только чтение boolean
```
#### Операции с деревом
```ts
node.children // только чтение FigmaNode[]
node.appendChild(child) // перемещает в конец
node.insertChild(index, child) // перемещает на позицию index
node.remove() // graph.deleteNode(id)
// Обход дерева
node.findAll(callback?) // рекурсивный поиск
node.findOne(callback) // первое совпадение
node.findChild(callback) // только среди прямых потомков
node.findChildren(callback?) // только среди прямых потомков
```
#### Автораскладка
```ts
node.layoutMode // 'NONE' | 'HORIZONTAL' | 'VERTICAL'
node.primaryAxisAlignItems // 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'
node.counterAxisAlignItems // 'MIN' | 'CENTER' | 'MAX' | 'BASELINE'
node.itemSpacing // number
node.counterAxisSpacing // number | null
node.paddingTop / Right / Bottom / Left // number
node.layoutWrap // 'NO_WRAP' | 'WRAP'
// Размеры дочерних элементов
node.layoutPositioning // 'AUTO' | 'ABSOLUTE'
node.layoutGrow // 0 | 1
node.layoutSizingHorizontal // 'FIXED' | 'HUG' | 'FILL'
node.layoutSizingVertical // 'FIXED' | 'HUG' | 'FILL'
```
#### Текст
```ts
node.characters // чтение/запись (соответствует node.text)
node.fontSize // чтение/запись
node.fontName // чтение/запись { family, style }
node.fontWeight // чтение/запись
node.textAlignHorizontal // чтение/запись
node.textAlignVertical // чтение/запись
node.textAutoResize // чтение/запись
node.letterSpacing // чтение/запись
node.lineHeight // чтение/запись
node.maxLines // чтение/запись
node.textCase // чтение/запись
node.textDecoration // чтение/запись
```
#### Параметры обводки
```ts
node.strokeWeight // чтение/запись (соответствует strokes[0].weight)
node.strokeAlign // чтение/запись (соответствует strokes[0].align)
node.dashPattern // чтение/запись
```
#### Прочее
```ts
figma.mixed // Symbol-маркер для смешанных значений
figma.group(nodes, parent) // создаёт GROUP с указанными дочерними элементами
figma.ungroup(node) // разгруппировывает, перемещает потомков к родителю
figma.flatten(nodes) // ПОКА НЕ РЕАЛИЗОВАНО — возвращает первый узел
```
#### Экспорт
```ts
node.exportAsync(settings?) // работает только при загруженном CanvasKit
// settings: { format: 'PNG'|'JPG'|'SVG', constraint? }
```
### Фаза 2: Компоненты и экземпляры
| API | Соответствие |
|-----|--------------|
| `figma.createComponent()` | `graph.createNode('COMPONENT', ...)` |
| `figma.createComponentFromNode(node)` | Преобразование существующего фрейма в компонент |
| `figma.combineAsVariants(components, parent)` | Создание COMPONENT_SET |
| Узел: `node.createInstance()` | `graph.createInstance(componentId, parentId)` |
| Узел: `node.detachInstance()` | `graph.detachInstance(id)` |
| `figma.getNodeById(id).mainComponent` | `graph.getMainComponent(id)` |
### Фаза 3: Переменные
| API | Соответствие |
|-----|--------------|
| `figma.variables.getLocalVariables(type?)` | `graph.variables` с фильтрацией |
| `figma.variables.getLocalVariableCollections()` | `graph.variableCollections` |
| `figma.variables.createVariable(name, collection, type)` | `graph.addVariable(...)` |
| `figma.variables.createVariableCollection(name)` | `graph.addCollection(...)` |
| `figma.variables.getVariableById(id)` | `graph.variables.get(id)` |
| `node.setBoundVariable(field, variable)` | `graph.bindVariable(...)` |
| `node.boundVariables` | Геттер из SceneNode |
### Фаза 4: Стили и расширенные возможности
| API | Примечания |
|-----|------------|
| `figma.createPaintStyle()` | Требует хранилища стилей в SceneGraph |
| `figma.createTextStyle()` | Требует хранилища стилей в SceneGraph |
| `figma.createEffectStyle()` | Требует хранилища стилей в SceneGraph |
| `figma.loadFontAsync(fontName)` | No-op (у нас нет ограничений на загрузку шрифтов) |
| `figma.listAvailableFontsAsync()` | Возвращает системные шрифты, если доступны |
| Булевы операции (`union`, `subtract`, `intersect`, `exclude`) | Требует движка булевых операций над путями |
| `figma.createNodeFromJSXAsync(jsx)` | Портирование JSX-рендерера из figma-use |
## Устройство прокси `FigmaNode`
Прокси оборачивает `SceneNode` и транслирует имена свойств Figma во внутренние имена. Основные соответствия:
```ts
const PROPERTY_MAP: Record<string, string> = {
// Имя Figma → поле SceneNode (только при различиях)
'characters': 'text',
'strokeWeight': → вычисляется из strokes[0].weight,
'strokeAlign': → вычисляется из strokes[0].align,
'fontName': → вычисляется из { family: fontFamily, style: ... },
'primaryAxisAlignItems': 'primaryAxisAlign',
'counterAxisAlignItems': 'counterAxisAlign',
'primaryAxisSizingMode': 'primaryAxisSizing', // маппинг значений: 'AUTO' → 'HUG', 'FIXED' → 'FIXED'
'counterAxisSizingMode': 'counterAxisSizing',
'layoutSizingHorizontal': → вычисляется из primaryAxisSizing / counterAxisSizing в зависимости от layoutMode
'layoutSizingVertical': → вычисляется
}
```
Методы прокси:
```ts
class FigmaNode {
// Прокси создаётся через: new Proxy(target, handler)
// handler.get перехватывает чтение свойств, handler.set — запись
resize(width: number, height: number): void
resizeWithoutConstraints(width: number, height: number): void
remove(): void
appendChild(child: FigmaNode): void
insertChild(index: number, child: FigmaNode): void
findAll(callback?: (node: FigmaNode) => boolean): FigmaNode[]
findOne(callback: (node: FigmaNode) => boolean): FigmaNode | null
findChild(callback: (node: FigmaNode) => boolean): FigmaNode | null
findChildren(callback?: (node: FigmaNode) => boolean): FigmaNode[]
exportAsync(settings?: ExportSettings): Promise<Uint8Array>
// Компоненты (Фаза 2)
createInstance(): FigmaNode
detachInstance(): void
get mainComponent(): FigmaNode | null
}
```
## CLI-команда
```
bun open-pencil eval <file> [options]
Аргументы:
file .fig-файл для обработки
Опции:
--code, -c JavaScript-код для выполнения (имеет доступ к глобальному объекту `figma`)
--stdin Читать код из stdin вместо --code
--write, -w Записать изменения обратно в исходный файл
-o, --output Записать в другой файл
--json Вывести результат в формате JSON (по умолчанию для не-TTY)
--quiet, -q Подавить вывод, только записать файл
```
### Модель выполнения
1. Загрузка `.fig` → `SceneGraph`
2. Создание `FigmaAPI(graph)` → прокси `figma`
3. Обёртка пользовательского кода в асинхронную функцию: `return (async () => { <код> })()`
4. Выполнение с `figma` в качестве единственного аргумента
5. Вывод возвращённого значения (JSON или agentfmt)
6. Если указан `--write` или `-o`: сериализация `SceneGraph` обратно в `.fig`
### Форматирование возвращаемого значения
- `undefined` / `void` → без вывода
- Примитивы → выводятся напрямую
- Объекты/массивы → `JSON.stringify(result, null, 2)` или таблицы agentfmt
- `FigmaNode` → сериализуется как `{ id, type, name, x, y, width, height, fills, ... }`
- Массивы `FigmaNode` → сериализуются как список
## Общий код с AI-инструментами
Класс `FigmaAPI` — это **та же поверхность API**, которую используют AI-инструменты. Сейчас `src/ai/tools.ts` вызывает `store.createShape()`, `store.updateNodeWithUndo()` и т.д. — их следует рефакторить для работы через `FigmaAPI`:
```ts
// До (текущие AI-инструменты)
execute: async ({ type, x, y, width, height }) => {
const id = store.createShape(type, x, y, width, height)
return { id }
}
// После (с использованием FigmaAPI)
execute: async ({ type, x, y, width, height }) => {
const frame = figma.createFrame()
frame.resize(width, height)
frame.x = x
frame.y = y
return { id: frame.id }
}
```
Это гарантирует идентичное поведение CLI-скриптов и AI-инструментов.
## Структура файлов
```
packages/core/src/
figma-api.ts # Класс FigmaAPI + прокси FigmaNode (Фазы 1–4)
figma-api.test.ts # Модульные тесты на headless SceneGraph
packages/cli/src/commands/
eval.ts # CLI-команда
packages/cli/src/commands/eval.test.ts # Интеграционные тесты
```
## План тестирования
### Модульные тесты (`packages/core/src/figma-api.test.ts`)
1. **Создание узлов** — каждый `createX()` создаёт узел правильного типа, добавленный на текущую страницу
2. **Доступ к свойствам** — `.fills`, `.x`, `.width`, `.name`, `.characters` корректно читаются и записываются
3. **Изменение размера** — `.resize(w, h)` обновляет width/height
4. **Операции с деревом** — `.appendChild()`, `.insertChild()`, `.remove()`, `.parent`, `.children`
5. **Обход дерева** — `.findAll()`, `.findOne()`, `.findChild()`, `.findChildren()` с колбэками
6. **Автораскладка** — `.layoutMode`, `.itemSpacing`, `.paddingTop` и т.д.
7. **Текст** — `.characters` соответствует `.text`, `.fontName` соответствует `{ family, style }`
8. **Смешанные значения** — `.cornerRadius` возвращает `figma.mixed`, когда углы различаются
9. **Выделение** — `figma.currentPage.selection` чтение/запись
10. **Переключение страниц** — `figma.currentPage = page2` работает
11. **Группировка/разгруппировка** — `figma.group()` создаёт группу, `figma.ungroup()` расформировывает её
12. **Клонирование** — создание узлов порождает независимые копии
### Интеграционные тесты CLI (`packages/cli/src/commands/eval.test.ts`)
1. **Базовый eval** — `eval test.fig --code 'return figma.currentPage.name'` → имя страницы
2. **Создание + чтение** — создать фрейм, вернуть его свойства
3. **Поиск узлов** — `findAll` возвращает правильные узлы
4. **Сохранение** — `--write` сохраняет изменения, при повторной загрузке они видны
5. **Stdin** — `echo 'return 42' | bun open-pencil eval test.fig --stdin` → `42`
6. **JSON-вывод** — `--json` возвращает валидный JSON
7. **Обработка ошибок** — синтаксические и ошибки времени выполнения корректно выводятся
## Порядок реализации
1. **Прокси `FigmaNode`** — маппинг свойств, `.resize()`, `.remove()`, методы работы с деревом
2. **Класс `FigmaAPI`** — `createFrame/Rectangle/...`, `.root`, `.currentPage`, `.getNodeById()`, `.mixed`, `.group()`
3. **CLI-команда `eval`** — разбор аргументов, обёртка кода, форматирование вывода
4. **Модульные тесты** — все 12 групп тестов выше
5. **Интеграционные тесты CLI** — все 7 групп тестов выше
6. **Интеграция с AI-инструментами** — рефакторинг `src/ai/tools.ts` для использования `FigmaAPI` где возможно
7. **Фаза 2** — компоненты и экземпляры
8. **Фаза 3** — переменные
9. **Фаза 4** — стили, булевы операции, JSX-рендерер
## Справочник соответствий свойств
| Свойство Figma | Поле SceneNode | Тип | Примечания |
|----------------|----------------|-----|------------|
| `characters` | `text` | `string` | |
| `fontName` | `fontFamily` + `fontWeight` + `italic` | `{ family, style }` | Вычисляется: `style` = "Bold Italic" и т.д. |
| `strokeWeight` | `strokes[0].weight` | `number` | Вычисляется |
| `strokeAlign` | `strokes[0].align` | `string` | Вычисляется |
| `primaryAxisAlignItems` | `primaryAxisAlign` | `string` | |
| `counterAxisAlignItems` | `counterAxisAlign` | `string` | |
| `layoutSizingHorizontal` | `primaryAxisSizing` или `counterAxisSizing` | `string` | Зависит от `layoutMode` |
| `layoutSizingVertical` | (противоположное horizontal) | `string` | |
| `absoluteTransform` | вычисляется из `x`, `y`, `rotation` | `Transform` | Только чтение |
| `absoluteBoundingBox` | `getAbsoluteBounds(id)` | `Rect` | Только чтение |
| Все остальные | Совпадающее имя | Тот же тип | Прямая передача |
## Открытые вопросы
1. **Загрузка шрифтов**: `figma.loadFontAsync()` — должно ли быть no-op (у нас нет ограничений на шрифты) или нужно отслеживать загруженные шрифты?
→ **Решение: No-op, возвращающий resolved Promise.** Мы не блокируем редактирование текста загрузкой шрифтов.
2. **Экспорт в headless-режиме**: `node.exportAsync()` требует CanvasKit. Должен ли eval загружать CanvasKit?
→ **Решение: Опционально.** Если CanvasKit доступен (через флаг `--with-canvaskit` или переменную окружения), экспорт включается. Иначе выбрасывается ошибка "Export requires CanvasKit".
3. **Символ `figma.mixed`**: Использовать настоящий символ Figma или собственный?
→ **Решение: Собственный `Symbol('mixed')`.** Доступен как `figma.mixed`.
4. **Отмена действий**: `figma.commitUndo()` / `figma.triggerUndo()` — актуально ли в headless-режиме?
→ **Решение: No-op в CLI.** Отмена имеет значение только в интерактивном редакторе. AI-инструменты могут добавить поддержку отмены отдельно через EditorStore.
5. **Формат записи**: Должен ли `--write` создавать `.fig` (бинарный Kiwi) или также поддерживать `.json`?
→ **Решение: Только `.fig` на данный момент.** Экспорт в JSON — отдельная функциональность.