openpencil/packages/core/src/render/renderer.ts
Anton A S d688d04bef Address PR #94 review: dedup, replace hand-rolled code, cleanup
- Replace calc.ts (150 lines) with expr-eval library
- Replace rgbLuminance with culori wcagLuminance
- Use @iconify/utils iconToSVG for viewBox normalization in iconify.ts
- Extract describe-shared.ts: findAncestorBackground, looksLikeButton, CONTAINER_TYPES
- Extract icon-render.ts: shared createIconFromPaths for create.ts and renderer.ts
- Inline codegen prompt as TS constant, remove runtime fs.readFile from MCP server
- Fix blur effect color alpha (a:1 → a:0)
- Encapsulate module-level mutable state in src/ai/tools.ts into RunState class
- Add network check to iconify tests (skip gracefully offline)
- Remove Caddyfile.dev and components.d.ts from tracking, add to .gitignore
2026-03-13 14:46:18 +03:00

537 lines
16 KiB
TypeScript
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.

import { parseColor, colorToFill } from '../color'
import { TRANSPARENT } from '../constants'
import { fetchIcons } from '../iconify'
import { createIconFromPaths } from '../icon-render'
import { isTreeNode } from './tree'
import type { SceneGraph, SceneNode, NodeType, LayoutMode, GridTrack, Stroke } from '../scene-graph'
import type { TreeNode } from './tree'
const TYPE_MAP: Partial<Record<string, NodeType>> = {
frame: 'FRAME',
view: 'FRAME',
rectangle: 'RECTANGLE',
rect: 'RECTANGLE',
ellipse: 'ELLIPSE',
text: 'TEXT',
line: 'LINE',
star: 'STAR',
polygon: 'POLYGON',
vector: 'VECTOR',
group: 'GROUP',
section: 'SECTION',
component: 'COMPONENT'
}
const WEIGHT_MAP: Record<string, number> = {
normal: 400,
medium: 500,
bold: 700
}
const ALIGN_MAP: Record<string, SceneNode['primaryAxisAlign']> = {
start: 'MIN',
end: 'MAX',
center: 'CENTER',
between: 'SPACE_BETWEEN'
}
const COUNTER_ALIGN_MAP: Record<string, 'MIN' | 'MAX' | 'CENTER' | 'STRETCH'> = {
start: 'MIN',
end: 'MAX',
center: 'CENTER',
stretch: 'STRETCH'
}
const TEXT_ALIGN_MAP: Record<string, SceneNode['textAlignHorizontal']> = {
left: 'LEFT',
center: 'CENTER',
right: 'RIGHT',
justified: 'JUSTIFIED'
}
const TEXT_AUTO_RESIZE_MAP: Record<string, SceneNode['textAutoResize']> = {
none: 'NONE',
width: 'WIDTH_AND_HEIGHT',
height: 'HEIGHT'
}
function parseStroke(value: string, width: number): Stroke {
const color = parseColor(value)
return {
color,
opacity: color.a,
visible: true,
weight: width,
align: 'INSIDE'
}
}
interface RenderOptions {
x?: number
y?: number
parentId?: string
}
export interface RenderResult {
id: string
name: string
type: NodeType
childIds: string[]
}
export async function renderTree(
graph: SceneGraph,
tree: TreeNode,
options: RenderOptions = {}
): Promise<RenderResult> {
const parentId = options.parentId ?? graph.getPages()[0].id
const result = await renderNode(graph, tree, parentId)
if (options.x !== undefined) graph.updateNode(result.id, { x: options.x })
if (options.y !== undefined) graph.updateNode(result.id, { y: options.y })
return {
id: result.id,
name: result.name,
type: result.type,
childIds: result.childIds
}
}
async function renderIconNode(
graph: SceneGraph,
tree: TreeNode,
parentId: string
): Promise<SceneNode> {
const props = tree.props
const iconName = props.name as string | undefined
if (!iconName) throw new Error('<Icon> requires a name prop (e.g. name="lucide:heart")')
const size = (props.size as number | undefined) ?? 24
const colorHex = (props.color as string | undefined) ?? '#000000'
const parsedColor = parseColor(colorHex)
const icons = await fetchIcons([iconName], size)
const icon = icons.get(iconName)
if (!icon || icon.paths.length === 0) {
throw new Error(`Icon "${iconName}" not found`)
}
const parent = graph.getNode(parentId)
const parentLayout = parent?.layoutMode ?? 'NONE'
const overrides: Partial<SceneNode> = {}
if (props.label) overrides.name = props.label as string
const { w, h } = applySizeOverrides(props, overrides, parentLayout)
if (typeof w !== 'number') overrides.width = size
if (typeof h !== 'number') overrides.height = size
return createIconFromPaths(graph, icon, iconName, size, parsedColor, parentId, overrides)
}
async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise<SceneNode> {
if (tree.type === 'icon') return renderIconNode(graph, tree, parentId)
const nodeType = TYPE_MAP[tree.type]
if (!nodeType) throw new Error(`Unknown element: <${tree.type}>`)
const parent = graph.getNode(parentId)
const parentLayout = parent?.layoutMode ?? 'NONE'
const isText = nodeType === 'TEXT'
const childCount = tree.children.filter((c) => typeof c !== 'string' && isTreeNode(c)).length
const overrides = propsToOverrides(tree.props, isText, parentLayout, childCount)
if (isText) {
const textContent = tree.children.filter((c): c is string => typeof c === 'string').join('')
if (textContent) overrides.text = textContent
}
const node = graph.createNode(nodeType, parentId, overrides)
for (const child of tree.children) {
if (typeof child === 'string') continue
if (isTreeNode(child)) {
await renderNode(graph, child, node.id)
}
}
return node
}
function applySizeOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>,
parentLayout: SceneNode['layoutMode']
): { w: unknown; h: unknown } {
const w = props.w ?? props.width
const h = props.h ?? props.height
if (typeof w === 'number') o.width = w
if (typeof h === 'number') o.height = h
const isParentRow = parentLayout === 'HORIZONTAL'
const isParentCol = parentLayout === 'VERTICAL'
const isParentGrid = parentLayout === 'GRID'
if (w === 'fill') {
if (isParentGrid) o.layoutAlignSelf = 'STRETCH'
else if (isParentRow) o.layoutGrow = 1
else if (isParentCol) o.layoutAlignSelf = 'STRETCH'
else { o.layoutGrow = 1; o.layoutAlignSelf = 'STRETCH' }
}
if (h === 'fill') {
if (isParentGrid) o.layoutAlignSelf = 'STRETCH'
else if (isParentCol) o.layoutGrow = 1
else if (isParentRow) o.layoutAlignSelf = 'STRETCH'
else o.layoutAlignSelf = 'STRETCH'
}
if (props.x !== undefined) o.x = props.x as number
if (props.y !== undefined) o.y = props.y as number
const hasExplicitPosition = props.x !== undefined || props.y !== undefined
const isInsideAutoLayout = parentLayout !== 'NONE'
if (hasExplicitPosition && isInsideAutoLayout) {
o.layoutPositioning = 'ABSOLUTE'
}
return { w, h }
}
function applyVisualOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const bg = props.bg ?? props.fill
if (typeof bg === 'string') {
o.fills = [colorToFill(bg)]
}
if (typeof props.stroke === 'string') {
const strokeWidth = (props.strokeWidth as number | undefined) ?? 1
o.strokes = [parseStroke(props.stroke, strokeWidth)]
}
const rounded = props.rounded ?? props.cornerRadius
if (typeof rounded === 'number') {
o.cornerRadius = rounded
}
if (
props.roundedTL !== undefined ||
props.roundedTR !== undefined ||
props.roundedBL !== undefined ||
props.roundedBR !== undefined
) {
o.independentCorners = true
if (props.roundedTL !== undefined) o.topLeftRadius = props.roundedTL as number
if (props.roundedTR !== undefined) o.topRightRadius = props.roundedTR as number
if (props.roundedBL !== undefined) o.bottomLeftRadius = props.roundedBL as number
if (props.roundedBR !== undefined) o.bottomRightRadius = props.roundedBR as number
}
if (props.cornerSmoothing !== undefined) o.cornerSmoothing = props.cornerSmoothing as number
if (props.opacity !== undefined) o.opacity = props.opacity as number
if (props.rotate !== undefined) o.rotation = props.rotate as number
if (props.blendMode !== undefined) {
o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode']
}
if (props.overflow === 'hidden') o.clipsContent = true
}
function applyPaddingOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const p = props.p ?? props.padding
if (typeof p === 'number') {
o.paddingTop = p
o.paddingRight = p
o.paddingBottom = p
o.paddingLeft = p
}
const px = props.px as number | undefined
const py = props.py as number | undefined
if (px !== undefined) {
o.paddingLeft = px
o.paddingRight = px
}
if (py !== undefined) {
o.paddingTop = py
o.paddingBottom = py
}
if (props.pt !== undefined) o.paddingTop = props.pt as number
if (props.pr !== undefined) o.paddingRight = props.pr as number
if (props.pb !== undefined) o.paddingBottom = props.pb as number
if (props.pl !== undefined) o.paddingLeft = props.pl as number
}
const PADDING_KEYS = ['p', 'padding', 'px', 'py', 'pt', 'pr', 'pb', 'pl'] as const
const AUTO_LAYOUT_TRIGGER_KEYS = [...PADDING_KEYS, 'justify', 'items'] as const
function hasAutoLayoutTriggerProps(props: Record<string, unknown>): boolean {
return AUTO_LAYOUT_TRIGGER_KEYS.some((k) => props[k] !== undefined)
}
function parseTrack(token: string): GridTrack {
if (token.endsWith('fr')) {
return { sizing: 'FR', value: parseFloat(token) || 1 }
}
if (token === 'auto') {
return { sizing: 'AUTO', value: 0 }
}
return { sizing: 'FIXED', value: parseFloat(token) || 0 }
}
function parseTrackList(value: string): GridTrack[] {
return value.trim().split(/\s+/).map(parseTrack)
}
function applyGridOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>,
w: unknown,
h: unknown
): void {
o.layoutMode = 'GRID'
if (typeof w === 'number') o.width = w
if (typeof h === 'number') o.height = h
if (typeof props.columns === 'string') {
o.gridTemplateColumns = parseTrackList(props.columns)
} else if (typeof props.columns === 'number') {
o.gridTemplateColumns = Array.from({ length: props.columns }, () => ({ sizing: 'FR' as const, value: 1 }))
}
if (typeof props.rows === 'string') {
o.gridTemplateRows = parseTrackList(props.rows)
} else if (typeof props.rows === 'number') {
o.gridTemplateRows = Array.from({ length: props.rows }, () => ({ sizing: 'FR' as const, value: 1 }))
}
if (typeof props.columnGap === 'number') o.gridColumnGap = props.columnGap
if (typeof props.rowGap === 'number') o.gridRowGap = props.rowGap
if (typeof props.gap === 'number') {
o.gridColumnGap = props.gap
o.gridRowGap = props.gap
}
if (props.rows === undefined && typeof h !== 'number') {
o.height = 0
}
}
function applyGridChildOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const col = props.colStart ?? props.col
const row = props.rowStart ?? props.row
const colSpan = (props.colSpan as number | undefined) ?? 1
const rowSpan = (props.rowSpan as number | undefined) ?? 1
if (col !== undefined || row !== undefined) {
o.gridPosition = {
column: (col as number | undefined) ?? 0,
row: (row as number | undefined) ?? 0,
columnSpan: colSpan,
rowSpan: rowSpan
}
}
}
function applyAutoLayoutSizing(
o: Partial<SceneNode>,
props: Record<string, unknown>,
w: unknown,
h: unknown
): void {
const dir = (props.flex as string | undefined) ?? 'col'
const isVertical = dir === 'col' || dir === 'column'
o.layoutMode = (isVertical ? 'VERTICAL' : 'HORIZONTAL') as LayoutMode
o.primaryAxisSizing = 'HUG'
o.counterAxisSizing = 'HUG'
const primaryDim = isVertical ? h : w
const counterDim = isVertical ? w : h
if (typeof primaryDim === 'number') o.primaryAxisSizing = 'FIXED'
if (typeof counterDim === 'number') o.counterAxisSizing = 'FIXED'
if (primaryDim === 'hug') o.primaryAxisSizing = 'HUG'
if (counterDim === 'hug') o.counterAxisSizing = 'HUG'
}
function shouldEnableAutoLayout(
props: Record<string, unknown>,
isText: boolean,
childCount: number
): boolean {
if (props.flex !== undefined) return true
if (!isText && hasAutoLayoutTriggerProps(props)) return true
if (!isText && !props.grid && childCount >= 2) return true
return false
}
function applyLayoutOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>,
w: unknown,
h: unknown,
isText: boolean,
parentLayout: SceneNode['layoutMode'],
childCount: number
): void {
if (props.grid) {
applyGridOverrides(props, o, w, h)
applyPaddingOverrides(props, o)
if (props.grow !== undefined) o.layoutGrow = props.grow as number
return
}
if (parentLayout === 'GRID') {
applyGridChildOverrides(props, o)
}
if (shouldEnableAutoLayout(props, isText, childCount)) {
applyAutoLayoutSizing(o, props, w, h)
}
if (props.gap !== undefined) o.itemSpacing = props.gap as number
if (props.wrap) {
o.layoutWrap = 'WRAP'
if (props.rowGap !== undefined) o.counterAxisSpacing = props.rowGap as number
}
if (props.justify) {
o.primaryAxisAlign = ALIGN_MAP[props.justify as string] ?? 'MIN'
}
if (props.items) {
o.counterAxisAlign = COUNTER_ALIGN_MAP[props.items as string] ?? 'MIN'
}
applyPaddingOverrides(props, o)
if (props.grow !== undefined) o.layoutGrow = props.grow as number
if (props.minW !== undefined) o.width = Math.max(o.width ?? 0, props.minW as number)
if (props.maxW !== undefined) o.width = Math.min(o.width ?? Infinity, props.maxW as number)
}
function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const fontSize = props.size ?? props.fontSize
if (typeof fontSize === 'number') o.fontSize = fontSize
const fontFamily = props.font ?? props.fontFamily
if (typeof fontFamily === 'string') o.fontFamily = fontFamily
const weight = props.weight ?? props.fontWeight
if (typeof weight === 'number') {
o.fontWeight = weight
} else if (typeof weight === 'string') {
o.fontWeight = WEIGHT_MAP[weight] ?? 400
}
if (typeof props.color === 'string') {
o.fills = [colorToFill(props.color)]
}
if (props.lineHeight !== undefined) o.lineHeight = props.lineHeight as number
if (props.letterSpacing !== undefined) o.letterSpacing = props.letterSpacing as number
if (props.textDecoration !== undefined) o.textDecoration = (props.textDecoration as string).toUpperCase() as SceneNode['textDecoration']
if (props.textCase !== undefined) o.textCase = (props.textCase as string).toUpperCase() as SceneNode['textCase']
if (props.maxLines !== undefined) {
o.maxLines = props.maxLines as number
o.textTruncation = 'ENDING'
}
if (props.truncate) {
o.textTruncation = 'ENDING'
}
if (props.textAlign) {
o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT'
}
}
function applyTextAutoResize(
props: Record<string, unknown>,
o: Partial<SceneNode>,
parentLayout: SceneNode['layoutMode']
): void {
const w = props.w ?? props.width
const hasExplicitWidth = w !== undefined
const fillsParent = w === 'fill' || (props.grow as number) > 0
const isInsideAutoLayout = parentLayout !== 'NONE'
// DO NOT CHANGE these defaults without testing headless layout (no CanvasKit).
// WIDTH_AND_HEIGHT relies on MeasureFunc — without it, text keeps the 100×100
// default SceneNode size and blows up every HUG container. layout.ts has a
// fallback estimator, but changing this logic can silently break all JSX rendering.
if (props.textAutoResize) {
o.textAutoResize = TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE'
} else if (hasExplicitWidth || (isInsideAutoLayout && fillsParent)) {
o.textAutoResize = 'HEIGHT'
} else {
o.textAutoResize = 'WIDTH_AND_HEIGHT'
}
}
function applyTextOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>,
parentLayout: SceneNode['layoutMode']
): void {
applyTextStyleOverrides(props, o)
applyTextAutoResize(props, o, parentLayout)
}
function applyShapeAndEffectOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
if (props.points !== undefined) o.pointCount = props.points as number
if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number
if (props.pointCount !== undefined) o.pointCount = props.pointCount as number
if (typeof props.shadow === 'string') {
const parts = props.shadow.split(/\s+/)
if (parts.length >= 4) {
const c = parseColor(parts.slice(3).join(' '))
o.effects = [
...(o.effects ?? []),
{
type: 'DROP_SHADOW',
color: c,
offset: { x: parseFloat(parts[0]), y: parseFloat(parts[1]) },
radius: parseFloat(parts[2]),
spread: 0,
visible: true
}
]
}
}
if (typeof props.blur === 'number') {
o.effects = [
...(o.effects ?? []),
{
type: 'LAYER_BLUR',
radius: props.blur,
visible: true,
color: { ...TRANSPARENT },
offset: { x: 0, y: 0 },
spread: 0
}
]
}
}
function propsToOverrides(
props: Record<string, unknown>,
isText: boolean,
parentLayout: SceneNode['layoutMode'],
childCount = 0
): Partial<SceneNode> {
const o: Partial<SceneNode> = {}
if (props.name) o.name = props.name as string
const { w, h } = applySizeOverrides(props, o, parentLayout)
applyVisualOverrides(props, o)
applyLayoutOverrides(props, o, w, h, isText, parentLayout, childCount)
if (isText) applyTextOverrides(props, o, parentLayout)
applyShapeAndEffectOverrides(props, o)
return o
}