399 lines
12 KiB
TypeScript
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 }
|
|
}
|