feat(dom-css): add SceneGraph conversion

This commit is contained in:
Danila Poyarkov 2026-06-02 15:16:13 +03:00
parent 4d61c002fa
commit 141d88cf94
6 changed files with 456 additions and 0 deletions

View file

@ -9,6 +9,7 @@
- Add structured design JSX paint helpers for solid fills, multiple fills, and gradients. - Add structured design JSX paint helpers for solid fills, multiple fills, and gradients.
- Add structured design JSX effect helpers for shadows and blur effects. - Add structured design JSX effect helpers for shadows and blur effects.
- Add the `@open-pencil/dom-css` package skeleton for DOM/CSS projection and browser/headless CSS runtime adapters. - Add the `@open-pencil/dom-css` package skeleton for DOM/CSS projection and browser/headless CSS runtime adapters.
- Add initial `@open-pencil/dom-css` DesignDOM ⇄ SceneGraph conversion helpers for HTML/CSS-shaped card layouts.
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`). - Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
- Add `unbind_variable` MCP tool for removing variable bindings. - Add `unbind_variable` MCP tool for removing variable bindings.
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output. - Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.

View file

@ -0,0 +1,51 @@
import { colorToCSS, parseColor } from '@open-pencil/core/color'
import type { Fill, SceneNode } from '@open-pencil/core/scene-graph'
import type { Color } from '@open-pencil/core/types'
import type { DesignStyleDeclaration } from './types'
const TRANSPARENT_KEYWORDS = new Set(['transparent', 'rgba(0, 0, 0, 0)', 'rgb(0 0 0 / 0)'])
export function parseCssNumber(value: string | undefined): number | null {
if (!value) return null
const trimmed = value.trim()
if (trimmed.length === 0 || trimmed === 'auto') return null
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : null
}
export function parseCssColor(value: string | undefined): Color | null {
if (!value) return null
const trimmed = value.trim()
if (trimmed.length === 0 || TRANSPARENT_KEYWORDS.has(trimmed.toLowerCase())) return null
return parseColor(trimmed)
}
export function fillToCss(fill: Fill | undefined): string | undefined {
if (fill?.type !== 'SOLID' || !fill.visible) return undefined
return colorToCSS({ ...fill.color, a: fill.opacity })
}
export function cssColorToFill(value: string | undefined): Fill[] {
const color = parseCssColor(value)
if (!color) return []
return [{ type: 'SOLID', color, opacity: color.a, visible: true }]
}
export function pickStyle(elementStyle: DesignStyleDeclaration | undefined, property: string) {
return elementStyle?.[property]
}
export function mergedStyle(node: {
inlineStyle?: DesignStyleDeclaration
computedStyle?: DesignStyleDeclaration
}) {
return { ...node.inlineStyle, ...node.computedStyle }
}
export function sceneNodeSizeStyle(node: SceneNode): DesignStyleDeclaration {
const style: DesignStyleDeclaration = {}
if (node.width > 0) style.width = `${node.width}px`
if (node.height > 0) style.height = `${node.height}px`
return style
}

View file

@ -0,0 +1,129 @@
import { colorToCSS } from '@open-pencil/core/color'
import { BLACK } from '@open-pencil/core/constants'
import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph'
import { fillToCss, sceneNodeSizeStyle } from './css-values'
import type { DesignDocument, DesignNode, DesignStyleDeclaration } from './types'
export interface SceneGraphToDesignOptions {
rootId?: string
includeSourceIds?: boolean
}
function nodeChildren(graph: SceneGraph, node: SceneNode): SceneNode[] {
return node.childIds
.map((id) => graph.getNode(id))
.filter((child): child is SceneNode => child !== undefined)
}
function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration {
const style = sceneNodeSizeStyle(node)
const fill = fillToCss(node.fills[0])
if (fill) style['background-color'] = fill
if (node.opacity < 1) style.opacity = String(node.opacity)
if (node.cornerRadius > 0) style['border-radius'] = `${node.cornerRadius}px`
if (node.layoutMode !== 'NONE') {
style.display = 'flex'
style['flex-direction'] = node.layoutMode === 'HORIZONTAL' ? 'row' : 'column'
if (node.itemSpacing > 0) style.gap = `${node.itemSpacing}px`
if (node.paddingTop > 0) style['padding-top'] = `${node.paddingTop}px`
if (node.paddingRight > 0) style['padding-right'] = `${node.paddingRight}px`
if (node.paddingBottom > 0) style['padding-bottom'] = `${node.paddingBottom}px`
if (node.paddingLeft > 0) style['padding-left'] = `${node.paddingLeft}px`
}
return style
}
function styleFromTextNode(node: SceneNode): DesignStyleDeclaration {
const style = sceneNodeSizeStyle(node)
style.color = fillToCss(node.fills[0]) ?? colorToCSS(BLACK)
style['font-family'] = node.fontFamily
style['font-size'] = `${node.fontSize}px`
style['font-weight'] = String(node.fontWeight)
if (node.italic) style['font-style'] = 'italic'
if (node.lineHeight !== null) style['line-height'] = `${node.lineHeight}px`
if (node.letterSpacing !== 0) style['letter-spacing'] = `${node.letterSpacing}px`
if (node.textAlignHorizontal !== 'LEFT')
style['text-align'] = node.textAlignHorizontal.toLowerCase()
if (node.textDecoration !== 'NONE') {
style['text-decoration-line'] =
node.textDecoration === 'UNDERLINE' ? 'underline' : 'line-through'
}
return style
}
function attrsForNode(node: SceneNode, includeSourceIds: boolean): Record<string, string> {
return includeSourceIds ? { 'data-open-pencil-node-id': node.id } : {}
}
function sceneNodeToDesignNode(
graph: SceneGraph,
node: SceneNode,
options: Required<SceneGraphToDesignOptions>
): DesignNode | null {
if (!node.visible || node.internalOnly) return null
if (node.type === 'TEXT') {
return {
type: 'element',
tagName: 'span',
attrs: attrsForNode(node, options.includeSourceIds),
inlineStyle: styleFromTextNode(node),
sourceSceneNodeId: node.id,
sourceSceneNode: node,
children: [{ type: 'text', text: node.text }]
}
}
const children = nodeChildren(graph, node)
.map((child) => sceneNodeToDesignNode(graph, child, options))
.filter((child): child is DesignNode => child !== null)
if (node.type === 'CANVAS') {
return {
type: 'element',
tagName: 'main',
attrs: attrsForNode(node, options.includeSourceIds),
sourceSceneNodeId: node.id,
sourceSceneNode: node,
children
}
}
return {
type: 'element',
tagName: 'div',
attrs: attrsForNode(node, options.includeSourceIds),
inlineStyle: styleFromSceneNode(node),
sourceSceneNodeId: node.id,
sourceSceneNode: node,
children
}
}
export function sceneGraphToDesignDocument(
graph: SceneGraph,
options: SceneGraphToDesignOptions = {}
): DesignDocument {
const root = graph.getNode(options.rootId ?? graph.rootId)
const resolvedOptions: Required<SceneGraphToDesignOptions> = {
rootId: options.rootId ?? graph.rootId,
includeSourceIds: options.includeSourceIds ?? true
}
const children = root
? nodeChildren(graph, root)
.map((child) => sceneNodeToDesignNode(graph, child, resolvedOptions))
.filter((child): child is DesignNode => child !== null)
: []
return {
type: 'document',
sourceGraph: graph,
children
}
}
export type { SceneGraphToDesignOptions as ToDesignDocumentOptions }

View file

@ -1,5 +1,9 @@
export { serializeHTML, serializeNode } from './serialize' export { serializeHTML, serializeNode } from './serialize'
export { createBrowserCssRuntime, createCssRuntime, createHeadlessCssRuntime } from './runtime' export { createBrowserCssRuntime, createCssRuntime, createHeadlessCssRuntime } from './runtime'
export { designDocumentToSceneGraph } from './to-scene-graph'
export { sceneGraphToDesignDocument } from './from-scene-graph'
export type { ToDesignDocumentOptions } from './from-scene-graph'
export type { ToSceneGraphOptions } from './to-scene-graph'
export type { export type {
CssComputeOptions, CssComputeOptions,
CssRuntime, CssRuntime,

View file

@ -0,0 +1,181 @@
import { SceneGraph, type Fill, type SceneNode } from '@open-pencil/core/scene-graph'
import { cssColorToFill, mergedStyle, parseCssNumber, pickStyle } from './css-values'
import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from './types'
export interface DesignDocumentToSceneGraphOptions {
pageName?: string
}
function textContent(node: DesignNode): string {
if (node.type === 'text') return node.text
return node.children.map(textContent).join('')
}
function isTextLikeElement(node: DesignElement): boolean {
return [
'span',
'p',
'label',
'strong',
'em',
'button',
'a',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6'
].includes(node.tagName.toLowerCase())
}
function firstCssNumber(style: DesignStyleDeclaration, ...properties: string[]): number | null {
for (const property of properties) {
const parsed = parseCssNumber(pickStyle(style, property))
if (parsed !== null) return parsed
}
return null
}
function fillsFromStyle(style: DesignStyleDeclaration, property: string): Fill[] {
return cssColorToFill(pickStyle(style, property))
}
function setNodeBox(node: SceneNode, style: DesignStyleDeclaration): void {
const width = firstCssNumber(style, 'width')
const height = firstCssNumber(style, 'height')
if (width !== null) node.width = width
if (height !== null) node.height = height
}
function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void {
setNodeBox(node, style)
const fills = fillsFromStyle(style, 'background-color')
if (fills.length > 0) node.fills = fills
const opacity = parseCssNumber(pickStyle(style, 'opacity'))
if (opacity !== null) node.opacity = opacity
const cornerRadius = firstCssNumber(style, 'border-radius')
if (cornerRadius !== null) node.cornerRadius = cornerRadius
if (pickStyle(style, 'display') === 'flex') {
node.layoutMode = pickStyle(style, 'flex-direction') === 'column' ? 'VERTICAL' : 'HORIZONTAL'
node.itemSpacing = firstCssNumber(style, 'gap', 'column-gap', 'row-gap') ?? 0
node.paddingTop = firstCssNumber(style, 'padding-top', 'padding') ?? 0
node.paddingRight = firstCssNumber(style, 'padding-right', 'padding') ?? 0
node.paddingBottom = firstCssNumber(style, 'padding-bottom', 'padding') ?? 0
node.paddingLeft = firstCssNumber(style, 'padding-left', 'padding') ?? 0
}
}
function applyTextStyle(node: SceneNode, style: DesignStyleDeclaration): void {
setNodeBox(node, style)
const fills = fillsFromStyle(style, 'color')
if (fills.length > 0) node.fills = fills
const fontSize = parseCssNumber(pickStyle(style, 'font-size'))
if (fontSize !== null) node.fontSize = fontSize
const fontWeight = parseCssNumber(pickStyle(style, 'font-weight'))
if (fontWeight !== null) node.fontWeight = fontWeight
const lineHeight = parseCssNumber(pickStyle(style, 'line-height'))
if (lineHeight !== null) node.lineHeight = lineHeight
const letterSpacing = parseCssNumber(pickStyle(style, 'letter-spacing'))
if (letterSpacing !== null) node.letterSpacing = letterSpacing
const fontFamily = pickStyle(style, 'font-family')
if (fontFamily)
node.fontFamily = fontFamily.split(',')[0]?.replaceAll('"', '').trim() || node.fontFamily
node.italic = pickStyle(style, 'font-style') === 'italic'
const textAlign = pickStyle(style, 'text-align')?.toUpperCase()
if (textAlign === 'CENTER' || textAlign === 'RIGHT' || textAlign === 'JUSTIFIED') {
node.textAlignHorizontal = textAlign
}
const textDecoration = pickStyle(style, 'text-decoration-line')
if (textDecoration === 'underline') node.textDecoration = 'UNDERLINE'
if (textDecoration === 'line-through') node.textDecoration = 'STRIKETHROUGH'
}
function createTextNode(
graph: SceneGraph,
parentId: string,
text: string,
style: DesignStyleDeclaration
) {
const node = graph.createNode('TEXT', parentId, {
name: text.slice(0, 32) || 'Text',
text,
width: Math.max(text.length * 8, 1),
height: 20
})
applyTextStyle(node, style)
return node
}
function createElementNode(graph: SceneGraph, parentId: string, element: DesignElement): SceneNode {
const style = mergedStyle(element)
if (isTextLikeElement(element) && element.children.every((child) => child.type === 'text')) {
return createTextNode(graph, parentId, textContent(element), style)
}
const node = graph.createNode('FRAME', parentId, {
name: element.attrs.id || element.attrs.class || element.tagName,
clipsContent: false
})
applyElementStyle(node, style)
for (const child of element.children) {
createDesignNode(graph, node.id, child, style)
}
return node
}
function createDesignNode(
graph: SceneGraph,
parentId: string,
node: DesignNode,
inheritedStyle: DesignStyleDeclaration = {}
): SceneNode | null {
if (node.type === 'text') {
if (node.text.trim().length === 0) return null
return createTextNode(graph, parentId, node.text, inheritedStyle)
}
return createElementNode(graph, parentId, node)
}
function fitPageToChildren(page: SceneNode, graph: SceneGraph): void {
const children = graph.getChildren(page.id)
if (children.length === 0) return
page.width = Math.max(...children.map((child) => child.x + child.width))
page.height = Math.max(...children.map((child) => child.y + child.height))
}
export function designDocumentToSceneGraph(
document: DesignDocument,
options: DesignDocumentToSceneGraphOptions = {}
): SceneGraph {
const graph = new SceneGraph()
const page = graph.getPages().find((node) => node.type === 'CANVAS') ?? graph.addPage('DesignDOM')
page.name = options.pageName ?? 'DesignDOM'
for (const child of document.children) {
createDesignNode(graph, page.id, child)
}
fitPageToChildren(page, graph)
return graph
}
export type { DesignDocumentToSceneGraphOptions as ToSceneGraphOptions }

View file

@ -0,0 +1,90 @@
import { describe, expect, it } from 'bun:test'
import {
designDocumentToSceneGraph,
sceneGraphToDesignDocument,
type DesignDocument
} from '@open-pencil/dom-css'
const cardDocument: DesignDocument = {
type: 'document',
children: [
{
type: 'element',
tagName: 'div',
attrs: { class: 'card' },
computedStyle: {
width: '320px',
height: '160px',
display: 'flex',
'flex-direction': 'column',
gap: '12px',
padding: '24px',
'border-radius': '16px',
'background-color': 'rgb(255, 255, 255)'
},
children: [
{
type: 'element',
tagName: 'h1',
attrs: {},
computedStyle: {
color: 'rgb(17, 24, 39)',
'font-size': '24px',
'font-weight': '700',
'line-height': '32px'
},
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
}
]
}
describe('@open-pencil/dom-css conversion', () => {
it('projects a DesignDOM card into a scene graph', () => {
const graph = designDocumentToSceneGraph(cardDocument)
const page = graph.getPages()[0]
expect(page?.name).toBe('DesignDOM')
const card = page ? graph.getChildren(page.id)[0] : undefined
expect(card?.type).toBe('FRAME')
expect(card?.width).toBe(320)
expect(card?.height).toBe(160)
expect(card?.layoutMode).toBe('VERTICAL')
expect(card?.itemSpacing).toBe(12)
expect(card?.paddingTop).toBe(24)
expect(card?.cornerRadius).toBe(16)
expect(card?.fills[0]?.type).toBe('SOLID')
const title = card ? graph.getChildren(card.id)[0] : undefined
expect(title?.type).toBe('TEXT')
expect(title?.text).toBe('OpenPencil')
expect(title?.fontSize).toBe(24)
expect(title?.fontWeight).toBe(700)
})
it('projects a scene graph back into DesignDOM', () => {
const graph = designDocumentToSceneGraph(cardDocument)
const document = sceneGraphToDesignDocument(graph)
const page = document.children[0]
expect(page?.type).toBe('element')
if (page?.type !== 'element') return
const card = page.children[0]
expect(card?.type).toBe('element')
if (card?.type !== 'element') return
expect(card.tagName).toBe('div')
expect(card.inlineStyle?.width).toBe('320px')
expect(card.inlineStyle?.display).toBe('flex')
expect(card.inlineStyle?.['flex-direction']).toBe('column')
expect(card.attrs['data-open-pencil-node-id']).toBeTruthy()
const title = card.children[0]
expect(title?.type).toBe('element')
if (title?.type !== 'element') return
expect(title.tagName).toBe('span')
expect(title.inlineStyle?.['font-size']).toBe('24px')
})
})