/** * 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 } }