v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user