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

24 KiB
Raw Blame History

open-pencil eval — Figma-совместимый Plugin API для скриптинга без GUI

Обзор

bun open-pencil eval <file> --code '<js>' выполняет JavaScript-код над .fig-файлом с глобальным объектом figma, совместимым с Figma. Это позволяет автоматизировать пакетные операции, запускать AI-инструменты и тесты — всё без графического интерфейса.

Объект figma максимально точно повторяет API плагинов Figma, поэтому существующие знания и фрагменты кода для плагинов Figma можно использовать напрямую.

# Создать фрейм, настроить автораскладку, добавить дочерние элементы
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:

// Геометрия
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

Операции с деревом

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?) // только среди прямых потомков

Автораскладка

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'

Текст

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          // чтение/запись

Параметры обводки

node.strokeWeight            // чтение/запись (соответствует strokes[0].weight)
node.strokeAlign             // чтение/запись (соответствует strokes[0].align)
node.dashPattern             // чтение/запись

Прочее

figma.mixed                  // Symbol-маркер для смешанных значений
figma.group(nodes, parent)   // создаёт GROUP с указанными дочерними элементами
figma.ungroup(node)          // разгруппировывает, перемещает потомков к родителю
figma.flatten(nodes)         // ПОКА НЕ РЕАЛИЗОВАНО — возвращает первый узел

Экспорт

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 во внутренние имена. Основные соответствия:

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': → вычисляется
}

Методы прокси:

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:

// До (текущие 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 — отдельная функциональность.