Files
Imhotep/packages/imhotep-cdp/src/geometry.ts
T

399 lines
12 KiB
TypeScript

/**
* Geometry Extraction
*
* Captures box models, fragment boxes, and transform data for DOM nodes
* using CDP DOM.getBoxModel and Runtime.evaluate.
*
* We batch requests where possible: gather all target node IDs first,
* then request box models in bulk via parallel sends.
*/
import type { CDPSession } from './session.js'
/**
* Raw CDP box model for a single node.
*/
export interface CDPBoxModel {
content: number[]
padding: number[]
border: number[]
margin: number[]
width: number
height: number
shapeOutside?: unknown
}
/**
* Canonical box geometry record for the geometry world.
*/
export interface BoxRecord {
boxId: number
subjectId: number
frameId: number
borderLeft: number
borderTop: number
borderRight: number
borderBottom: number
paddingLeft: number
paddingTop: number
paddingRight: number
paddingBottom: number
contentLeft: number
contentTop: number
contentRight: number
contentBottom: number
}
/**
* Fragment record for multi-line or multi-column content.
*/
export interface FragmentRecord {
fragmentId: number
subjectId: number
fragmentKind: number
boxLeft: number
boxTop: number
boxRight: number
boxBottom: number
lineIndex: number
flowIndex: number
parentFragmentId: number
}
/**
* Transform record for a subject.
*/
export interface TransformRecord {
transformId: number
subjectId: number
matrixStart: number
matrixLength: number
originX: number
originY: number
}
/**
* Geometry extraction result.
*/
export interface GeometryExtractionResult {
boxes: BoxRecord[]
fragments: FragmentRecord[]
transforms: TransformRecord[]
/** Flat array of matrix values (6 elements per 2D matrix) */
matrices: number[]
}
/**
* Extract box models for a list of node IDs.
*
* Sends DOM.getBoxModel for each node in parallel to minimize
* round-trip latency.
*/
export async function extractBoxModels(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{ boxes: BoxRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
const boxes: BoxRecord[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
// Batch: request all box models in parallel using backendNodeId.
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
try {
const response = await session.send<{ model: CDPBoxModel }>('DOM.getBoxModel', {
backendNodeId,
})
return { status: 'ok' as const, model: response.model, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const model = result.model
const subjectId = subjectIds[result.idx]
// CDP returns quads as [x1,y1,x2,y2,x3,y3,x4,y4].
// For axis-aligned boxes, we use the first two points.
const borderQuad = model.border
const paddingQuad = model.padding
const contentQuad = model.content
boxes.push({
boxId: boxes.length,
subjectId,
frameId: 0, // Frame resolution happens later.
borderLeft: Math.min(borderQuad[0], borderQuad[2]),
borderTop: Math.min(borderQuad[1], borderQuad[5]),
borderRight: Math.max(borderQuad[4], borderQuad[6]),
borderBottom: Math.max(borderQuad[3], borderQuad[7]),
paddingLeft: Math.min(paddingQuad[0], paddingQuad[2]),
paddingTop: Math.min(paddingQuad[1], paddingQuad[5]),
paddingRight: Math.max(paddingQuad[4], paddingQuad[6]),
paddingBottom: Math.max(paddingQuad[3], paddingQuad[7]),
contentLeft: Math.min(contentQuad[0], contentQuad[2]),
contentTop: Math.min(contentQuad[1], contentQuad[5]),
contentRight: Math.max(contentQuad[4], contentQuad[6]),
contentBottom: Math.max(contentQuad[3], contentQuad[7]),
})
}
return { boxes, errors }
}
/**
* Extract client rect fragments for a list of backend node IDs.
*
* Resolves each backendNodeId to a RemoteObject via DOM.resolveNode,
* then uses Runtime.callFunctionOn to invoke getClientRects() directly
* on the element. This avoids DOM mutation and fragile attribute queries.
*/
export async function extractFragments(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{ fragments: FragmentRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
const fragments: FragmentRecord[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
try {
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
backendNodeId,
})
const response = await session.send<{
result: {
value: Array<{ left: number; top: number; right: number; bottom: number }>
}
}>('Runtime.callFunctionOn', {
objectId: resolved.object.objectId,
functionDeclaration: `function() {
const rects = this.getClientRects()
const boxes = []
for (let i = 0; i < rects.length; i++) {
boxes.push({
left: rects[i].left,
top: rects[i].top,
right: rects[i].right,
bottom: rects[i].bottom,
})
}
return boxes
}`,
returnByValue: true,
})
return { status: 'ok' as const, boxes: response.result.value, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const subjectId = subjectIds[result.idx]
for (let j = 0; j < (result.boxes?.length ?? 0); j++) {
const box = result.boxes![j]
fragments.push({
fragmentId: fragments.length,
subjectId,
fragmentKind: 1, // 1 = client rect fragment
boxLeft: box.left,
boxTop: box.top,
boxRight: box.right,
boxBottom: box.bottom,
lineIndex: j,
flowIndex: 0,
parentFragmentId: 0,
})
}
}
return { fragments, errors }
}
/**
* Extract visual bounding boxes for a list of backend node IDs.
*
* Uses getBoundingClientRect() via Runtime.callFunctionOn to obtain
* post-transform viewport coordinates. These are stored in visualBoxes
* and represent the effective rendered position after all CSS transforms.
*/
export async function extractVisualBoxes(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{ boxes: BoxRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
const boxes: BoxRecord[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
try {
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
backendNodeId,
})
const response = await session.send<{
result: {
value: {
left: number
top: number
right: number
bottom: number
width: number
height: number
}
}
}>('Runtime.callFunctionOn', {
objectId: resolved.object.objectId,
functionDeclaration: `function() {
const rect = this.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
}
}`,
returnByValue: true,
})
return { status: 'ok' as const, rect: response.result.value, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const rect = result.rect
const subjectId = subjectIds[result.idx]
boxes.push({
boxId: boxes.length,
subjectId,
frameId: 0,
borderLeft: rect.left,
borderTop: rect.top,
borderRight: rect.right,
borderBottom: rect.bottom,
paddingLeft: rect.left,
paddingTop: rect.top,
paddingRight: rect.right,
paddingBottom: rect.bottom,
contentLeft: rect.left,
contentTop: rect.top,
contentRight: rect.right,
contentBottom: rect.bottom,
})
}
return { boxes, errors }
}
/**
* Extract CSS transform matrices for a list of node IDs.
*
* Returns the computed 2D transform matrix (6 values) and origin
* for each node that has a transform.
*/
export async function extractTransforms(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{
transforms: TransformRecord[]
matrices: number[]
errors: Array<{ backendNodeId: number; reason: string }>
}> {
const transforms: TransformRecord[] = []
const matrices: number[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
try {
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
backendNodeId,
})
const response = await session.send<{
result: {
value: {
matrix: number[]
originX: number
originY: number
}
}
}>('Runtime.callFunctionOn', {
objectId: resolved.object.objectId,
functionDeclaration: `function() {
const style = window.getComputedStyle(this)
const transform = style.transform
let matrix = [1, 0, 0, 1, 0, 0]
if (transform && transform !== 'none') {
const match = transform.match(/matrix\\(([^)]+)\\)/)
if (match) {
matrix = match[1].split(',').map(Number)
}
}
const origin = style.transformOrigin.split(' ').map(parseFloat)
return {
matrix,
originX: origin[0] || 0,
originY: origin[1] || 0,
}
}`,
returnByValue: true,
})
return { status: 'ok' as const, data: response.result.value, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const matrix = result.data.matrix ?? [1, 0, 0, 1, 0, 0]
const matrixStart = matrices.length
matrices.push(...matrix)
transforms.push({
transformId: transforms.length,
subjectId: subjectIds[result.idx],
matrixStart,
matrixLength: matrix.length,
originX: result.data.originX ?? 0,
originY: result.data.originY ?? 0,
})
}
return { transforms, matrices, errors }
}