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. |
|
| `position: absolute/fixed`, `left`, `top` | `layoutPositioning`, `x`, `y` | Right/bottom constraints are not mapped yet. |
|
||||||
| `overflow: hidden/clip` | `clipsContent` | Other overflow values are ignored. |
|
| `overflow: hidden/clip` | `clipsContent` | Other overflow values are ignored. |
|
||||||
| `width`, `height`, min/max sizes | node size constraints | Browser-computed pixel values are preferred. |
|
| `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
|
## 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. |
|
| `border-radius`, `border-*-radius` | corner radii | Independent corners are preserved when sides differ. |
|
||||||
| `opacity` | node opacity | Numeric computed value. |
|
| `opacity` | node opacity | Numeric computed value. |
|
||||||
| `box-shadow` | drop shadow | Simple shadows only; see parser audit before expanding. |
|
| `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
|
## 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:
|
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
|
- complex gradients
|
||||||
- CSS filters
|
- CSS filters
|
||||||
- multi-shadow lists
|
- multi-shadow lists
|
||||||
|
|
@ -60,4 +61,4 @@ These values are collected or covered by browser oracle tests but do not yet hav
|
||||||
|
|
||||||
## Headless limitations
|
## 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`
|
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 {
|
function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration {
|
||||||
const style = sceneNodeSizeStyle(node)
|
const style = sceneNodeSizeStyle(node)
|
||||||
addPositioning(style, node)
|
addPositioning(style, node)
|
||||||
addSizeConstraints(style, node)
|
addSizeConstraints(style, node)
|
||||||
const fill = fillToCSS(node.fills[0])
|
const fill = fillToCSS(node.fills[0])
|
||||||
if (fill) style['background-color'] = fill
|
if (fill) style['background-color'] = fill
|
||||||
|
addImageStyle(style, node)
|
||||||
addStroke(style, node)
|
addStroke(style, node)
|
||||||
const shadow = dropShadowToCSS(node.effects[0])
|
const shadow = dropShadowToCSS(node.effects[0])
|
||||||
if (shadow) style['box-shadow'] = shadow
|
if (shadow) style['box-shadow'] = shadow
|
||||||
|
|
@ -197,8 +206,31 @@ function styleFromTextNode(node: SceneNode): DesignStyleDeclaration {
|
||||||
return style
|
return style
|
||||||
}
|
}
|
||||||
|
|
||||||
function attrsForNode(node: SceneNode, includeSourceIds: boolean): Record<string, string> {
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
return includeSourceIds ? { 'data-open-pencil-node-id': node.id } : {}
|
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(
|
function sceneNodeToDesignNode(
|
||||||
|
|
@ -212,7 +244,7 @@ function sceneNodeToDesignNode(
|
||||||
return {
|
return {
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'span',
|
tagName: 'span',
|
||||||
attrs: attrsForNode(node, options.includeSourceIds),
|
attrs: attrsForNode(graph, node, options.includeSourceIds),
|
||||||
inlineStyle: styleFromTextNode(node),
|
inlineStyle: styleFromTextNode(node),
|
||||||
sourceSceneNodeId: node.id,
|
sourceSceneNodeId: node.id,
|
||||||
sourceSceneNode: node,
|
sourceSceneNode: node,
|
||||||
|
|
@ -228,7 +260,7 @@ function sceneNodeToDesignNode(
|
||||||
return {
|
return {
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'main',
|
tagName: 'main',
|
||||||
attrs: attrsForNode(node, options.includeSourceIds),
|
attrs: attrsForNode(graph, node, options.includeSourceIds),
|
||||||
sourceSceneNodeId: node.id,
|
sourceSceneNodeId: node.id,
|
||||||
sourceSceneNode: node,
|
sourceSceneNode: node,
|
||||||
children
|
children
|
||||||
|
|
@ -237,8 +269,8 @@ function sceneNodeToDesignNode(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'div',
|
tagName: tagNameForNode(node),
|
||||||
attrs: attrsForNode(node, options.includeSourceIds),
|
attrs: attrsForNode(graph, node, options.includeSourceIds),
|
||||||
inlineStyle: styleFromSceneNode(node),
|
inlineStyle: styleFromSceneNode(node),
|
||||||
sourceSceneNodeId: node.id,
|
sourceSceneNodeId: node.id,
|
||||||
sourceSceneNode: node,
|
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 {
|
import {
|
||||||
colorToFillFromCSS,
|
colorToFillFromCSS,
|
||||||
|
|
@ -49,6 +57,17 @@ function fillsFromStyle(style: DesignStyleDeclaration, property: string): Fill[]
|
||||||
return colorToFillFromCSS(pickStyle(style, property))
|
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 {
|
function setNodeBox(node: SceneNode, style: DesignStyleDeclaration): void {
|
||||||
const width = firstCSSNumber(style, 'width')
|
const width = firstCSSNumber(style, 'width')
|
||||||
const height = firstCSSNumber(style, 'height')
|
const height = firstCSSNumber(style, 'height')
|
||||||
|
|
@ -56,8 +75,11 @@ function setNodeBox(node: SceneNode, style: DesignStyleDeclaration): void {
|
||||||
const maxWidth = firstCSSNumber(style, 'max-width')
|
const maxWidth = firstCSSNumber(style, 'max-width')
|
||||||
const minHeight = firstCSSNumber(style, 'min-height')
|
const minHeight = firstCSSNumber(style, 'min-height')
|
||||||
const maxHeight = firstCSSNumber(style, 'max-height')
|
const maxHeight = firstCSSNumber(style, 'max-height')
|
||||||
|
const aspectRatio = aspectRatioFromCSS(pickStyle(style, 'aspect-ratio'))
|
||||||
if (width !== null) node.width = width
|
if (width !== null) node.width = width
|
||||||
if (height !== null) node.height = height
|
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 (minWidth !== null) node.minWidth = minWidth
|
||||||
if (maxWidth !== null) node.maxWidth = maxWidth
|
if (maxWidth !== null) node.maxWidth = maxWidth
|
||||||
if (minHeight !== null) node.minHeight = minHeight
|
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
|
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)
|
setNodeBox(node, style)
|
||||||
applyPositioning(node, style)
|
applyPositioning(node, style)
|
||||||
applyPadding(node, style)
|
applyPadding(node, style)
|
||||||
|
|
||||||
const fills = fillsFromStyle(style, 'background-color')
|
const fills = fillsFromStyle(style, 'background-color')
|
||||||
if (fills.length > 0) node.fills = fills
|
if (fills.length > 0) node.fills = fills
|
||||||
|
applyImageFill(graph, node, element, style)
|
||||||
|
|
||||||
const strokes = colorToStrokeFromCSS(
|
const strokes = colorToStrokeFromCSS(
|
||||||
firstStrokeColor(style),
|
firstStrokeColor(style),
|
||||||
|
|
@ -311,6 +379,7 @@ function createTextNode(
|
||||||
|
|
||||||
function hasBoxStyle(style: DesignStyleDeclaration): boolean {
|
function hasBoxStyle(style: DesignStyleDeclaration): boolean {
|
||||||
return [
|
return [
|
||||||
|
'aspect-ratio',
|
||||||
'background-color',
|
'background-color',
|
||||||
'border-color',
|
'border-color',
|
||||||
'border-style',
|
'border-style',
|
||||||
|
|
@ -339,6 +408,7 @@ function hasBoxStyle(style: DesignStyleDeclaration): boolean {
|
||||||
'max-width',
|
'max-width',
|
||||||
'min-height',
|
'min-height',
|
||||||
'max-height',
|
'max-height',
|
||||||
|
'object-fit',
|
||||||
'overflow',
|
'overflow',
|
||||||
'position',
|
'position',
|
||||||
'top',
|
'top',
|
||||||
|
|
@ -364,7 +434,7 @@ function createElementNode(graph: SceneGraph, parentId: string, element: DesignE
|
||||||
name: element.attrs.id || element.attrs.class || element.tagName,
|
name: element.attrs.id || element.attrs.class || element.tagName,
|
||||||
clipsContent: false
|
clipsContent: false
|
||||||
})
|
})
|
||||||
applyElementStyle(node, style)
|
applyElementStyle(graph, node, element, style)
|
||||||
|
|
||||||
for (const child of element.children) {
|
for (const child of element.children) {
|
||||||
createDesignNode(graph, node.id, child, style)
|
createDesignNode(graph, node.id, child, style)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import {
|
||||||
} from '../src/index'
|
} from '../src/index'
|
||||||
import { TEST_COLORS, cardCSS, cardHTML, fixtureCSS, fixtureHTML } from './helpers'
|
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) {
|
function expectFrame(node: SceneNode | undefined) {
|
||||||
expect(node?.type).toBe('FRAME')
|
expect(node?.type).toBe('FRAME')
|
||||||
if (node?.type !== 'FRAME') throw new Error('Expected frame node')
|
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)
|
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', () => {
|
it('round-trips logical padding, side borders, opacity, and text style fields', () => {
|
||||||
const graph = createStyleRoundTripGraph()
|
const graph = createStyleRoundTripGraph()
|
||||||
const panel = expectStyleRoundTripPanel(graph)
|
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) {
|
export async function publicBrowserTextNode(page: Page, html: string, cssText: string) {
|
||||||
await ensureAppPage(page)
|
await ensureAppPage(page)
|
||||||
await page.setContent('<main></main>')
|
await page.setContent('<main></main>')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue