feat(dom-css): map image aspect styles

This commit is contained in:
Danila Poyarkov 2026-06-06 19:32:18 +03:00
parent 49fefcd3a0
commit 346d13c405
6 changed files with 244 additions and 12 deletions

View file

@ -16,6 +16,7 @@ OpenPencil maps browser-computed DOM/CSS styles into SceneGraph fields through `
| `position: absolute/fixed`, `left`, `top` | `layoutPositioning`, `x`, `y` | Right/bottom constraints are not mapped yet. |
| `overflow: hidden/clip` | `clipsContent` | Other overflow values are ignored. |
| `width`, `height`, min/max sizes | node size constraints | Browser-computed pixel values are preferred. |
| `aspect-ratio` | fallback width/height sizing | Used when one axis is available and the other is `auto`/missing. |
## Paint, stroke, and effects
@ -28,6 +29,8 @@ OpenPencil maps browser-computed DOM/CSS styles into SceneGraph fields through `
| `border-radius`, `border-*-radius` | corner radii | Independent corners are preserved when sides differ. |
| `opacity` | node opacity | Numeric computed value. |
| `box-shadow` | drop shadow | Simple shadows only; see parser audit before expanding. |
| `<img src="data:...">` | image fill | Data URL images are stored in the graph image map. External URL fetching is not performed. |
| `object-fit: contain/cover` | image `FIT` / `FILL` scale mode | `scale-down` maps to `FIT`; other object-fit values are not mapped yet. |
## Text
@ -50,8 +53,6 @@ OpenPencil maps browser-computed DOM/CSS styles into SceneGraph fields through `
These values are collected or covered by browser oracle tests but do not yet have a stable SceneGraph mapping:
- `aspect-ratio`
- `object-fit`
- complex gradients
- CSS filters
- multi-shadow lists
@ -60,4 +61,4 @@ These values are collected or covered by browser oracle tests but do not yet hav
## Headless limitations
The headless runtime uses maintained parsers for HTML (`parse5`) and stylesheets (`@acemir/cssom`), but still has limited approximations for selector matching, shorthand expansion, inline style text, `calc()`, and simple shadows. Do not expand those with ad hoc parsers. See [`../development/dom-css-parser-audit.md`](../development/dom-css-parser-audit.md).
The headless runtime uses maintained parsers for HTML (`parse5`) and stylesheets/inline declarations (`@acemir/cssom`), but still has limited approximations for selector matching, shorthand expansion, `calc()`, and simple shadows. Do not expand those with ad hoc parsers. See [`../development/dom-css-parser-audit.md`](../development/dom-css-parser-audit.md).

View file

@ -142,12 +142,21 @@ function addFlexGap(style: DesignStyleDeclaration, node: SceneNode): void {
style['column-gap'] = `${node.counterAxisSpacing}px`
}
function addImageStyle(style: DesignStyleDeclaration, node: SceneNode): void {
const fill = node.fills[0]
if (fill.type !== 'IMAGE' || !fill.visible) return
if (node.width > 0 && node.height > 0) style['aspect-ratio'] = `${node.width} / ${node.height}`
if (fill.imageScaleMode === 'FIT') style['object-fit'] = 'contain'
if (fill.imageScaleMode === 'FILL') style['object-fit'] = 'cover'
}
function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration {
const style = sceneNodeSizeStyle(node)
addPositioning(style, node)
addSizeConstraints(style, node)
const fill = fillToCSS(node.fills[0])
if (fill) style['background-color'] = fill
addImageStyle(style, node)
addStroke(style, node)
const shadow = dropShadowToCSS(node.effects[0])
if (shadow) style['box-shadow'] = shadow
@ -197,8 +206,31 @@ function styleFromTextNode(node: SceneNode): DesignStyleDeclaration {
return style
}
function attrsForNode(node: SceneNode, includeSourceIds: boolean): Record<string, string> {
return includeSourceIds ? { 'data-open-pencil-node-id': node.id } : {}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return globalThis.btoa(binary)
}
function attrsForNode(
graph: SceneGraph,
node: SceneNode,
includeSourceIds: boolean
): Record<string, string> {
const attrs: Record<string, string> = includeSourceIds
? { 'data-open-pencil-node-id': node.id }
: {}
const fill = node.fills[0]
if (fill.type !== 'IMAGE' || !fill.imageHash) return attrs
const bytes = graph.images.get(fill.imageHash)
if (!bytes) return attrs
return { ...attrs, src: `data:image/png;base64,${bytesToBase64(bytes)}` }
}
function tagNameForNode(node: SceneNode): string {
const fill = node.fills[0]
if (fill.type === 'IMAGE' && node.childIds.length === 0) return 'img'
return 'div'
}
function sceneNodeToDesignNode(
@ -212,7 +244,7 @@ function sceneNodeToDesignNode(
return {
type: 'element',
tagName: 'span',
attrs: attrsForNode(node, options.includeSourceIds),
attrs: attrsForNode(graph, node, options.includeSourceIds),
inlineStyle: styleFromTextNode(node),
sourceSceneNodeId: node.id,
sourceSceneNode: node,
@ -228,7 +260,7 @@ function sceneNodeToDesignNode(
return {
type: 'element',
tagName: 'main',
attrs: attrsForNode(node, options.includeSourceIds),
attrs: attrsForNode(graph, node, options.includeSourceIds),
sourceSceneNodeId: node.id,
sourceSceneNode: node,
children
@ -237,8 +269,8 @@ function sceneNodeToDesignNode(
return {
type: 'element',
tagName: 'div',
attrs: attrsForNode(node, options.includeSourceIds),
tagName: tagNameForNode(node),
attrs: attrsForNode(graph, node, options.includeSourceIds),
inlineStyle: styleFromSceneNode(node),
sourceSceneNodeId: node.id,
sourceSceneNode: node,

View file

@ -1,4 +1,12 @@
import { SceneGraph, type Fill, type SceneNode, type Stroke } from '@open-pencil/core/scene-graph'
import { TRANSPARENT } from '@open-pencil/core/constants'
import { computeImageHash } from '@open-pencil/core/figma-api'
import {
SceneGraph,
type Fill,
type ImageScaleMode,
type SceneNode,
type Stroke
} from '@open-pencil/core/scene-graph'
import {
colorToFillFromCSS,
@ -49,6 +57,17 @@ function fillsFromStyle(style: DesignStyleDeclaration, property: string): Fill[]
return colorToFillFromCSS(pickStyle(style, property))
}
function aspectRatioFromCSS(value: string | undefined): number | null {
if (!value || value === 'auto') return null
const parts = value
.split('/')
.map((part) => Number.parseFloat(part.trim()))
.filter(Number.isFinite)
if (parts.length === 1 && parts[0] > 0) return parts[0]
if (parts.length === 2 && parts[0] > 0 && parts[1] > 0) return parts[0] / parts[1]
return null
}
function setNodeBox(node: SceneNode, style: DesignStyleDeclaration): void {
const width = firstCSSNumber(style, 'width')
const height = firstCSSNumber(style, 'height')
@ -56,8 +75,11 @@ function setNodeBox(node: SceneNode, style: DesignStyleDeclaration): void {
const maxWidth = firstCSSNumber(style, 'max-width')
const minHeight = firstCSSNumber(style, 'min-height')
const maxHeight = firstCSSNumber(style, 'max-height')
const aspectRatio = aspectRatioFromCSS(pickStyle(style, 'aspect-ratio'))
if (width !== null) node.width = width
if (height !== null) node.height = height
if (height === null && width !== null && aspectRatio !== null) node.height = width / aspectRatio
if (width === null && height !== null && aspectRatio !== null) node.width = height * aspectRatio
if (minWidth !== null) node.minWidth = minWidth
if (maxWidth !== null) node.maxWidth = maxWidth
if (minHeight !== null) node.minHeight = minHeight
@ -201,13 +223,59 @@ function applyPadding(node: SceneNode, style: DesignStyleDeclaration): void {
node.paddingLeft = firstCSSNumber(style, 'padding-left', 'padding-inline', 'padding') ?? 0
}
function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void {
function imageScaleModeFromObjectFit(value: string | undefined): ImageScaleMode | null {
if (value === 'contain' || value === 'scale-down') return 'FIT'
if (value === 'cover') return 'FILL'
return null
}
function bytesFromDataURL(value: string | undefined): Uint8Array | null {
if (!value?.startsWith('data:')) return null
const commaIndex = value.indexOf(',')
if (commaIndex === -1) return null
const metadata = value.slice(0, commaIndex)
const body = value.slice(commaIndex + 1)
if (!metadata.endsWith(';base64')) return null
const binary = globalThis.atob(body)
return Uint8Array.from(binary, (char) => char.charCodeAt(0))
}
function applyImageFill(
graph: SceneGraph,
node: SceneNode,
element: DesignElement,
style: DesignStyleDeclaration
): void {
if (element.tagName.toLowerCase() !== 'img') return
const bytes = bytesFromDataURL(element.attrs.src)
if (!bytes) return
const imageHash = computeImageHash(bytes)
graph.images.set(imageHash, bytes)
node.fills = [
{
type: 'IMAGE',
imageHash,
imageScaleMode: imageScaleModeFromObjectFit(pickStyle(style, 'object-fit')) ?? 'FILL',
color: TRANSPARENT,
opacity: 1,
visible: true
}
]
}
function applyElementStyle(
graph: SceneGraph,
node: SceneNode,
element: DesignElement,
style: DesignStyleDeclaration
): void {
setNodeBox(node, style)
applyPositioning(node, style)
applyPadding(node, style)
const fills = fillsFromStyle(style, 'background-color')
if (fills.length > 0) node.fills = fills
applyImageFill(graph, node, element, style)
const strokes = colorToStrokeFromCSS(
firstStrokeColor(style),
@ -311,6 +379,7 @@ function createTextNode(
function hasBoxStyle(style: DesignStyleDeclaration): boolean {
return [
'aspect-ratio',
'background-color',
'border-color',
'border-style',
@ -339,6 +408,7 @@ function hasBoxStyle(style: DesignStyleDeclaration): boolean {
'max-width',
'min-height',
'max-height',
'object-fit',
'overflow',
'position',
'top',
@ -364,7 +434,7 @@ function createElementNode(graph: SceneGraph, parentId: string, element: DesignE
name: element.attrs.id || element.attrs.class || element.tagName,
clipsContent: false
})
applyElementStyle(node, style)
applyElementStyle(graph, node, element, style)
for (const child of element.children) {
createDesignNode(graph, node.id, child, style)

View file

@ -13,6 +13,9 @@ import {
} from '../src/index'
import { TEST_COLORS, cardCSS, cardHTML, fixtureCSS, fixtureHTML } from './helpers'
const TRANSPARENT_PIXEL_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
function expectFrame(node: SceneNode | undefined) {
expect(node?.type).toBe('FRAME')
if (node?.type !== 'FRAME') throw new Error('Expected frame node')
@ -254,6 +257,47 @@ describe('@open-pencil/dom-css conversion', () => {
expect(card.strokes[0]?.weight).toBe(1)
})
it('maps data URL images, object fit, and aspect ratio', () => {
const graph = designDocumentToSceneGraph({
type: 'document',
children: [
{
type: 'element',
tagName: 'img',
attrs: { src: TRANSPARENT_PIXEL_DATA_URL },
computedStyle: {
'aspect-ratio': '16 / 9',
width: '320px',
'object-fit': 'contain'
},
children: []
}
]
})
const page = graph.getPages()[0]
const image = expectFrame(page ? graph.getChildren(page.id)[0] : undefined)
const fill = image.fills[0]
expect(image.width).toBe(320)
expect(image.height).toBe(180)
expect(fill?.type).toBe('IMAGE')
expect(fill?.imageScaleMode).toBe('FIT')
expect(fill?.imageHash).toBeDefined()
expect(fill?.imageHash ? graph.images.has(fill.imageHash) : false).toBe(true)
const roundTrip = sceneGraphToDesignDocument(graph)
const root = roundTrip.children[0]
expect(root?.type).toBe('element')
if (root?.type !== 'element') throw new Error('Expected root element')
const roundTripImage = root.children[0]
expect(roundTripImage?.type).toBe('element')
if (roundTripImage?.type !== 'element') throw new Error('Expected image element')
expect(roundTripImage.tagName).toBe('img')
expect(roundTripImage.inlineStyle?.['aspect-ratio']).toBe('320 / 180')
expect(roundTripImage.inlineStyle?.['object-fit']).toBe('contain')
expect(roundTripImage.attrs.src).toStartWith('data:image/png;base64,')
})
it('round-trips logical padding, side borders, opacity, and text style fields', () => {
const graph = createStyleRoundTripGraph()
const panel = expectStyleRoundTripPanel(graph)

View file

@ -0,0 +1,59 @@
import {
computedStyleProperties,
publicBrowserImageNode,
setStyledContent
} from '#tests/helpers/dom-css-browser'
import { expect, test } from '../fixtures'
const TRANSPARENT_PIXEL_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
test.describe('@open-pencil/dom-css browser CSS media and image oracle', () => {
test('resolves media queries and inherited em/rem units in a real browser', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 600 })
await setStyledContent(
page,
`
:root { font-size: 10px; }
.panel {
font-size: 20px;
width: 20rem;
padding: 2em;
}
@media (min-width: 800px) {
.panel { width: 30rem; }
}
`,
'<section class="panel">OpenPencil</section>'
)
const widePanel = await computedStyleProperties(page, '.panel', [
'font-size',
'padding-left',
'width'
])
expect(widePanel['font-size']).toBe('20px')
expect(widePanel['padding-left']).toBe('40px')
expect(widePanel.width).toBe('300px')
await page.setViewportSize({ width: 640, height: 600 })
const narrowPanel = await computedStyleProperties(page, '.panel', ['width'])
expect(narrowPanel.width).toBe('200px')
})
test('projects browser image sizing and object fit into scene graph fields', async ({ page }) => {
const imageNode = await publicBrowserImageNode(
page,
`<img class="media" alt="Preview" src="${TRANSPARENT_PIXEL_DATA_URL}" />`,
'.media { aspect-ratio: 16 / 9; object-fit: contain; width: 320px; }'
)
expect(imageNode?.type).toBe('FRAME')
expect(imageNode?.width).toBe(320)
expect(imageNode?.height).toBe(180)
expect(imageNode?.fillType).toBe('IMAGE')
expect(imageNode?.imageScaleMode).toBe('FIT')
expect(imageNode?.hasImageBytes).toBe(true)
})
})

View file

@ -99,6 +99,32 @@ export async function publicBrowserSceneGraph(page: Page, classes: string[], css
)
}
export async function publicBrowserImageNode(page: Page, html: string, cssText: string) {
await ensureAppPage(page)
await page.setContent('<main></main>')
return page.evaluate(
async ({ sourceHTML, css, modulePath }) => {
const { browserHTMLToSceneGraph } = await import(modulePath)
const graph = await browserHTMLToSceneGraph(sourceHTML, { cssText: css })
const pageNode = graph.getPages()[0]
const image = pageNode ? graph.getChildren(pageNode.id)[0] : undefined
const fill = image?.fills[0]
return image
? {
fillType: fill?.type,
hasImageBytes: fill?.imageHash ? graph.images.has(fill.imageHash) : false,
height: image.height,
imageScaleMode: fill?.imageScaleMode,
type: image.type,
width: image.width
}
: null
},
{ sourceHTML: html, css: cssText, modulePath: DOM_CSS_BROWSER_MODULE }
)
}
export async function publicBrowserTextNode(page: Page, html: string, cssText: string) {
await ensureAppPage(page)
await page.setContent('<main></main>')