feat(dom-css): map image aspect styles
This commit is contained in:
parent
49fefcd3a0
commit
346d13c405
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
59
tests/e2e/dom-css/browser-runtime-media.spec.ts
Normal file
59
tests/e2e/dom-css/browser-runtime-media.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>')
|
||||
|
|
|
|||
Loading…
Reference in a new issue