From 346d13c4053b681cbbf67424316319fd1dca96f4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 6 Jun 2026 19:32:18 +0300 Subject: [PATCH] feat(dom-css): map image aspect styles --- packages/docs/reference/dom-css-mapping.md | 7 +- packages/dom-css/src/from-scene-graph.ts | 44 +++++++++-- packages/dom-css/src/to-scene-graph.ts | 76 ++++++++++++++++++- packages/dom-css/tests/conversion.test.ts | 44 +++++++++++ .../e2e/dom-css/browser-runtime-media.spec.ts | 59 ++++++++++++++ tests/helpers/dom-css-browser.ts | 26 +++++++ 6 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/dom-css/browser-runtime-media.spec.ts 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; } + } + `, + '
OpenPencil
' + ) + + 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, + `Preview`, + '.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('
')