diff --git a/packages/docs/reference/dom-css-mapping.md b/packages/docs/reference/dom-css-mapping.md
index 90bc4e46e..fa0cadc18 100644
--- a/packages/docs/reference/dom-css-mapping.md
+++ b/packages/docs/reference/dom-css-mapping.md
@@ -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. |
+| `
` | 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).
diff --git a/packages/dom-css/src/from-scene-graph.ts b/packages/dom-css/src/from-scene-graph.ts
index 6658cf97e..85b3fd4de 100644
--- a/packages/dom-css/src/from-scene-graph.ts
+++ b/packages/dom-css/src/from-scene-graph.ts
@@ -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 {
- 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 {
+ const attrs: Record = 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,
diff --git a/packages/dom-css/src/to-scene-graph.ts b/packages/dom-css/src/to-scene-graph.ts
index cb1662d9d..31829cdf3 100644
--- a/packages/dom-css/src/to-scene-graph.ts
+++ b/packages/dom-css/src/to-scene-graph.ts
@@ -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)
diff --git a/packages/dom-css/tests/conversion.test.ts b/packages/dom-css/tests/conversion.test.ts
index 1813ee814..623257c1a 100644
--- a/packages/dom-css/tests/conversion.test.ts
+++ b/packages/dom-css/tests/conversion.test.ts
@@ -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)
diff --git a/tests/e2e/dom-css/browser-runtime-media.spec.ts b/tests/e2e/dom-css/browser-runtime-media.spec.ts
new file mode 100644
index 000000000..d7e3a4897
--- /dev/null
+++ b/tests/e2e/dom-css/browser-runtime-media.spec.ts
@@ -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; }
+ }
+ `,
+ ''
+ )
+
+ 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,
+ `
`,
+ '.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)
+ })
+})
diff --git a/tests/helpers/dom-css-browser.ts b/tests/helpers/dom-css-browser.ts
index 2c01417d4..0136d614d 100644
--- a/tests/helpers/dom-css-browser.ts
+++ b/tests/helpers/dom-css-browser.ts
@@ -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('')
+
+ 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('')