From 141d88cf94fa96301a38df3efc6be3a09f0697a6 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 2 Jun 2026 15:16:13 +0300 Subject: [PATCH] feat(dom-css): add SceneGraph conversion --- CHANGELOG.md | 1 + packages/dom-css/src/css-values.ts | 51 +++++++ packages/dom-css/src/from-scene-graph.ts | 129 ++++++++++++++++ packages/dom-css/src/index.ts | 4 + packages/dom-css/src/to-scene-graph.ts | 181 +++++++++++++++++++++++ tests/engine/dom-css/conversion.test.ts | 90 +++++++++++ 6 files changed, 456 insertions(+) create mode 100644 packages/dom-css/src/css-values.ts create mode 100644 packages/dom-css/src/from-scene-graph.ts create mode 100644 packages/dom-css/src/to-scene-graph.ts create mode 100644 tests/engine/dom-css/conversion.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b83924b68..15d6a49d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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 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 `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. diff --git a/packages/dom-css/src/css-values.ts b/packages/dom-css/src/css-values.ts new file mode 100644 index 000000000..3e597a106 --- /dev/null +++ b/packages/dom-css/src/css-values.ts @@ -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 +} diff --git a/packages/dom-css/src/from-scene-graph.ts b/packages/dom-css/src/from-scene-graph.ts new file mode 100644 index 000000000..73d43e829 --- /dev/null +++ b/packages/dom-css/src/from-scene-graph.ts @@ -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 { + return includeSourceIds ? { 'data-open-pencil-node-id': node.id } : {} +} + +function sceneNodeToDesignNode( + graph: SceneGraph, + node: SceneNode, + options: Required +): 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 = { + 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 } diff --git a/packages/dom-css/src/index.ts b/packages/dom-css/src/index.ts index 91b90addb..2318610b5 100644 --- a/packages/dom-css/src/index.ts +++ b/packages/dom-css/src/index.ts @@ -1,5 +1,9 @@ export { serializeHTML, serializeNode } from './serialize' 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 { CssComputeOptions, CssRuntime, diff --git a/packages/dom-css/src/to-scene-graph.ts b/packages/dom-css/src/to-scene-graph.ts new file mode 100644 index 000000000..7cff39660 --- /dev/null +++ b/packages/dom-css/src/to-scene-graph.ts @@ -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 } diff --git a/tests/engine/dom-css/conversion.test.ts b/tests/engine/dom-css/conversion.test.ts new file mode 100644 index 000000000..06fcc257d --- /dev/null +++ b/tests/engine/dom-css/conversion.test.ts @@ -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') + }) +})