v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "imhotep-geometry",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/anomalyco/imhotep.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --test dist/**/*.test.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// Box and fragment processing for Imhotep geometry world
|
||||
|
||||
import {
|
||||
GeometryWorld,
|
||||
Boxes,
|
||||
Fragments,
|
||||
Rects,
|
||||
WorldIndex,
|
||||
DisplayValue,
|
||||
PositionValue,
|
||||
} from './world.js'
|
||||
import {
|
||||
convertRect,
|
||||
resolveViewportFrame,
|
||||
walkFrameAncestors,
|
||||
} from './frames.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Box geometry helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the border box for a box at the given index.
|
||||
*/
|
||||
export function getBorderBox(
|
||||
boxes: Boxes,
|
||||
index: number
|
||||
): { left: number; top: number; right: number; bottom: number } {
|
||||
return {
|
||||
left: boxes.borderLeft[index],
|
||||
top: boxes.borderTop[index],
|
||||
right: boxes.borderRight[index],
|
||||
bottom: boxes.borderBottom[index],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the padding box for a box at the given index.
|
||||
*/
|
||||
export function getPaddingBox(
|
||||
boxes: Boxes,
|
||||
index: number
|
||||
): { left: number; top: number; right: number; bottom: number } {
|
||||
return {
|
||||
left: boxes.paddingLeft[index],
|
||||
top: boxes.paddingTop[index],
|
||||
right: boxes.paddingRight[index],
|
||||
bottom: boxes.paddingBottom[index],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content box for a box at the given index.
|
||||
*/
|
||||
export function getContentBox(
|
||||
boxes: Boxes,
|
||||
index: number
|
||||
): { left: number; top: number; right: number; bottom: number } {
|
||||
return {
|
||||
left: boxes.contentLeft[index],
|
||||
top: boxes.contentTop[index],
|
||||
right: boxes.contentRight[index],
|
||||
bottom: boxes.contentBottom[index],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the margin box for a box at the given index.
|
||||
* Margin is inferred as the space between border and padding boxes.
|
||||
*/
|
||||
export function getMarginBox(
|
||||
boxes: Boxes,
|
||||
index: number
|
||||
): { left: number; top: number; right: number; bottom: number } {
|
||||
const border = getBorderBox(boxes, index)
|
||||
const padding = getPaddingBox(boxes, index)
|
||||
return {
|
||||
left: border.left - (padding.left - border.left),
|
||||
top: border.top - (padding.top - border.top),
|
||||
right: border.right + (border.right - padding.right),
|
||||
bottom: border.bottom + (border.bottom - padding.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute width and height of a rect.
|
||||
*/
|
||||
export function rectSize(rect: {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}): { width: number; height: number } {
|
||||
return {
|
||||
width: rect.right - rect.left,
|
||||
height: rect.bottom - rect.top,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the area of a rect.
|
||||
*/
|
||||
export function rectArea(rect: {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}): number {
|
||||
return (rect.right - rect.left) * (rect.bottom - rect.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two rects intersect.
|
||||
*/
|
||||
export function rectsIntersect(
|
||||
a: { left: number; top: number; right: number; bottom: number },
|
||||
b: { left: number; top: number; right: number; bottom: number }
|
||||
): boolean {
|
||||
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the intersection of two rects.
|
||||
* Returns null if they do not intersect.
|
||||
*/
|
||||
export function rectIntersection(
|
||||
a: { left: number; top: number; right: number; bottom: number },
|
||||
b: { left: number; top: number; right: number; bottom: number }
|
||||
): { left: number; top: number; right: number; bottom: number } | null {
|
||||
const left = Math.max(a.left, b.left)
|
||||
const top = Math.max(a.top, b.top)
|
||||
const right = Math.min(a.right, b.right)
|
||||
const bottom = Math.min(a.bottom, b.bottom)
|
||||
if (left >= right || top >= bottom) return null
|
||||
return { left, top, right, bottom }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fragment geometry helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the bounding box of a fragment.
|
||||
*/
|
||||
export function getFragmentBox(
|
||||
fragments: Fragments,
|
||||
index: number
|
||||
): { left: number; top: number; right: number; bottom: number } {
|
||||
return {
|
||||
left: fragments.boxLeft[index],
|
||||
top: fragments.boxTop[index],
|
||||
right: fragments.boxRight[index],
|
||||
bottom: fragments.boxBottom[index],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all fragment indices belonging to a subject.
|
||||
*/
|
||||
export function getSubjectFragments(
|
||||
world: GeometryWorld,
|
||||
subjectId: number,
|
||||
index: WorldIndex
|
||||
): number[] {
|
||||
const subjIdx = index.subjectById.get(subjectId)
|
||||
if (subjIdx === undefined) return []
|
||||
|
||||
const firstFragId = world.subjects.firstFragmentId[subjIdx]
|
||||
const fragCount = world.subjects.fragmentCount[subjIdx]
|
||||
if (firstFragId === 0 || fragCount === 0) return []
|
||||
|
||||
const result: number[] = []
|
||||
for (let i = 0; i < world.fragments.fragmentId.length; i++) {
|
||||
if (
|
||||
world.fragments.fragmentId[i] >= firstFragId &&
|
||||
world.fragments.fragmentId[i] < firstFragId + fragCount
|
||||
) {
|
||||
result.push(i)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the union bounding box of all fragments for a subject.
|
||||
*/
|
||||
export function getFragmentUnionBox(
|
||||
world: GeometryWorld,
|
||||
subjectId: number,
|
||||
index: WorldIndex
|
||||
): { left: number; top: number; right: number; bottom: number } | null {
|
||||
const frags = getSubjectFragments(world, subjectId, index)
|
||||
if (frags.length === 0) return null
|
||||
|
||||
let left = Infinity,
|
||||
top = Infinity,
|
||||
right = -Infinity,
|
||||
bottom = -Infinity
|
||||
|
||||
for (const idx of frags) {
|
||||
const box = getFragmentBox(world.fragments, idx)
|
||||
left = Math.min(left, box.left)
|
||||
top = Math.min(top, box.top)
|
||||
right = Math.max(right, box.right)
|
||||
bottom = Math.max(bottom, box.bottom)
|
||||
}
|
||||
|
||||
return { left, top, right, bottom }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate conversion for boxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a box's border box to a different frame.
|
||||
*/
|
||||
export function convertBoxToFrame(
|
||||
world: GeometryWorld,
|
||||
boxIndex: number,
|
||||
targetFrameIndex: number,
|
||||
index: WorldIndex
|
||||
): { left: number; top: number; right: number; bottom: number } | null {
|
||||
const sourceFrameIndex = index.frameById.get(world.boxes.frameId[boxIndex])
|
||||
if (sourceFrameIndex === undefined) return null
|
||||
|
||||
const box = getBorderBox(world.boxes, boxIndex)
|
||||
return convertRect(
|
||||
world,
|
||||
sourceFrameIndex,
|
||||
targetFrameIndex,
|
||||
box.left,
|
||||
box.top,
|
||||
box.right,
|
||||
box.bottom,
|
||||
index
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Box queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the primary box index for a subject.
|
||||
*/
|
||||
export function getPrimaryBoxIndex(
|
||||
world: GeometryWorld,
|
||||
subjectId: number,
|
||||
index: WorldIndex
|
||||
): number | undefined {
|
||||
const subjIdx = index.subjectById.get(subjectId)
|
||||
if (subjIdx === undefined) return undefined
|
||||
const boxId = world.subjects.primaryBoxId[subjIdx]
|
||||
if (boxId === 0) return undefined
|
||||
return index.boxById.get(boxId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary box rect in the viewport frame.
|
||||
*/
|
||||
export function getPrimaryBoxInViewport(
|
||||
world: GeometryWorld,
|
||||
subjectId: number,
|
||||
index: WorldIndex
|
||||
): { left: number; top: number; right: number; bottom: number } | null {
|
||||
const boxIdx = getPrimaryBoxIndex(world, subjectId, index)
|
||||
if (boxIdx === undefined) return null
|
||||
|
||||
const viewportFrame = resolveViewportFrame(world.frames)
|
||||
if (viewportFrame === -1) return null
|
||||
|
||||
return convertBoxToFrame(world, boxIdx, viewportFrame, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the visible area of a box by intersecting with clipping rects.
|
||||
*/
|
||||
export function computeVisibleBox(
|
||||
world: GeometryWorld,
|
||||
boxIndex: number,
|
||||
index: WorldIndex
|
||||
): { left: number; top: number; right: number; bottom: number } | null {
|
||||
const box = getBorderBox(world.boxes, boxIndex)
|
||||
const subjectId = world.boxes.subjectId[boxIndex]
|
||||
|
||||
// Start with the box itself
|
||||
const visible = { ...box }
|
||||
|
||||
// Walk clipping chain
|
||||
const clipNodeId = world.clipping.subjectId.indexOf(subjectId)
|
||||
if (clipNodeId === -1) return visible
|
||||
|
||||
// Simple case: just intersect with direct clip
|
||||
// Full clipping chain walk would require building a parent chain
|
||||
const clipLeft = world.clipping.clipLeft[clipNodeId]
|
||||
const clipTop = world.clipping.clipTop[clipNodeId]
|
||||
const clipRight = world.clipping.clipRight[clipNodeId]
|
||||
const clipBottom = world.clipping.clipBottom[clipNodeId]
|
||||
|
||||
const clipRect = { left: clipLeft, top: clipTop, right: clipRight, bottom: clipBottom }
|
||||
const inter = rectIntersection(visible, clipRect)
|
||||
if (!inter) return null
|
||||
|
||||
return inter
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
// Unit and property tests for domain index construction and querying (V1.1)
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import fc from 'fast-check'
|
||||
|
||||
import {
|
||||
GeometryWorld,
|
||||
StringTable,
|
||||
SubjectKind,
|
||||
DisplayValue,
|
||||
PositionValue,
|
||||
OverflowValue,
|
||||
VisibilityValue,
|
||||
internString,
|
||||
FragmentKind,
|
||||
} from './world.js'
|
||||
|
||||
import {
|
||||
getElementsBySelector,
|
||||
getDescendants,
|
||||
getLineBoxes,
|
||||
getTextRuns,
|
||||
} from './domain-index.js'
|
||||
|
||||
import { queryDomain } from './query-index.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers: build minimal GeometryWorlds with domain indexes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeStringTable(...values: string[]): StringTable {
|
||||
const table: StringTable = { values: [] }
|
||||
for (const v of values) {
|
||||
internString(table, v)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
function makeWorldWithElements(
|
||||
elements: Array<{ id: number; tag: string; classes: string[]; parentNodeId: number }>,
|
||||
): GeometryWorld {
|
||||
const strings = makeStringTable('scene', 'snap', 'dark', 'fine', 'none', 'en-US', 'horizontal-tb', ...elements.flatMap(e => [e.tag, ...e.classes]))
|
||||
|
||||
const subjectCount = elements.length
|
||||
const subjects = {
|
||||
ids: new Uint32Array(elements.map(e => e.id)),
|
||||
domNodeId: new Uint32Array(elements.map(e => e.id)),
|
||||
subjectKind: new Uint16Array(subjectCount).fill(SubjectKind.Element),
|
||||
primaryBoxId: new Uint32Array(subjectCount),
|
||||
firstFragmentId: new Uint32Array(subjectCount),
|
||||
fragmentCount: new Uint16Array(subjectCount),
|
||||
firstTextRunId: new Uint32Array(subjectCount),
|
||||
textRunCount: new Uint16Array(subjectCount),
|
||||
}
|
||||
|
||||
const dom = {
|
||||
nodeId: new Uint32Array(elements.map(e => e.id)),
|
||||
backendNodeId: new Uint32Array(elements.map(e => e.id)),
|
||||
parentNodeId: new Uint32Array(elements.map(e => e.parentNodeId)),
|
||||
firstChildIndex: new Uint32Array(subjectCount),
|
||||
childCount: new Uint16Array(subjectCount),
|
||||
shadowRootKind: new Uint8Array(subjectCount),
|
||||
tagNameStringId: new Uint32Array(elements.map(e => internString(strings, e.tag))),
|
||||
classNameStringIds: elements.map(e => {
|
||||
const ids = new Uint32Array(e.classes.length)
|
||||
for (let i = 0; i < e.classes.length; i++) {
|
||||
ids[i] = internString(strings, e.classes[i])
|
||||
}
|
||||
return ids
|
||||
}),
|
||||
roleStringId: new Uint32Array(subjectCount),
|
||||
ariaNameStringId: new Uint32Array(subjectCount),
|
||||
}
|
||||
|
||||
const emptyEnv = {
|
||||
viewportWidth: 1440,
|
||||
viewportHeight: 900,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: internString(strings, 'dark'),
|
||||
pointer: internString(strings, 'fine'),
|
||||
hover: internString(strings, 'none'),
|
||||
reducedMotion: false,
|
||||
locale: internString(strings, 'en-US'),
|
||||
writingMode: internString(strings, 'horizontal-tb'),
|
||||
}
|
||||
|
||||
const emptySource = {
|
||||
url: internString(strings, 'https://example.test'),
|
||||
browserName: internString(strings, 'Chromium'),
|
||||
browserVersion: internString(strings, '123'),
|
||||
engine: internString(strings, 'chromium-cdp'),
|
||||
extractedAt: Date.now(),
|
||||
}
|
||||
|
||||
const emptyArrays = {
|
||||
frameId: new Uint32Array(0),
|
||||
frameKind: new Uint16Array(0),
|
||||
ownerSubjectId: new Uint32Array(0),
|
||||
parentFrameId: new Uint32Array(0),
|
||||
originX: new Float64Array(0),
|
||||
originY: new Float64Array(0),
|
||||
axisMatrixStart: new Int32Array(0),
|
||||
clipRectId: new Uint32Array(0),
|
||||
scrollContainerId: new Uint32Array(0),
|
||||
writingMode: new Uint16Array(0),
|
||||
}
|
||||
|
||||
const world: GeometryWorld = {
|
||||
sceneId: internString(strings, 'scene'),
|
||||
snapshotId: internString(strings, 'snap'),
|
||||
env: emptyEnv,
|
||||
source: emptySource,
|
||||
strings,
|
||||
subjects,
|
||||
dom,
|
||||
frames: emptyArrays,
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
|
||||
boxes: { boxId: new Uint32Array(0), subjectId: new Uint32Array(0), frameId: new Uint32Array(0), borderLeft: new Float64Array(0), borderTop: new Float64Array(0), borderRight: new Float64Array(0), borderBottom: new Float64Array(0), paddingLeft: new Float64Array(0), paddingTop: new Float64Array(0), paddingRight: new Float64Array(0), paddingBottom: new Float64Array(0), contentLeft: new Float64Array(0), contentTop: new Float64Array(0), contentRight: new Float64Array(0), contentBottom: new Float64Array(0) },
|
||||
fragments: { fragmentId: new Uint32Array(0), subjectId: new Uint32Array(0), fragmentKind: new Uint16Array(0), boxLeft: new Float64Array(0), boxTop: new Float64Array(0), boxRight: new Float64Array(0), boxBottom: new Float64Array(0), lineIndex: new Int32Array(0), flowIndex: new Int32Array(0), parentFragmentId: new Uint32Array(0) },
|
||||
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Int32Array(0), matrixLength: new Uint16Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
|
||||
styles: { subjectId: new Uint32Array(0), display: new Uint16Array(0), position: new Uint16Array(0), zIndexKind: new Uint8Array(0), zIndexValue: new Int32Array(0), overflowX: new Uint16Array(0), overflowY: new Uint16Array(0), opacity: new Float32Array(0), visibility: new Uint16Array(0), containFlags: new Uint32Array(0), pointerEvents: new Uint16Array(0), lineHeight: new Float64Array(0), fontFamilyStringId: new Uint32Array(0), fontSize: new Float64Array(0), fontWeight: new Uint16Array(0) },
|
||||
text: { runId: new Uint32Array(0), subjectId: new Uint32Array(0), contentStringId: new Uint32Array(0), lineBoxId: new Uint32Array(0), inkLeft: new Float64Array(0), inkTop: new Float64Array(0), inkRight: new Float64Array(0), inkBottom: new Float64Array(0), baselineY: new Float64Array(0), capHeight: new Float64Array(0), computedLineHeight: new Float64Array(0) },
|
||||
topology: { containingBlockOf: new Uint32Array(0), nearestPositionedAncestorOf: new Uint32Array(0), scrollContainerOf: new Uint32Array(0), stackingContextOf: new Uint32Array(0), formattingContextOf: new Uint32Array(0), clippingRootOf: new Uint32Array(0), paintOrderBucket: new Uint16Array(0), paintOrderIndex: new Uint32Array(0) },
|
||||
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
|
||||
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
|
||||
paint: { paintNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), stackingContextId: new Uint32Array(0), bucket: new Uint16Array(0), localPaintIndex: new Uint32Array(0) },
|
||||
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
|
||||
provenance: { factId: new Uint32Array(0), extractionStepId: new Uint32Array(0), sourceKind: new Uint16Array(0), sourceRef: new Uint32Array(0) },
|
||||
confidence: { factId: new Uint32Array(0), confidence: new Float32Array(0), reasonCode: new Uint16Array(0) },
|
||||
selectorIndex: new Map<string, number[]>(),
|
||||
ancestorIndex: new Map<number, number[]>(),
|
||||
lineBoxIndex: new Map<number, number[]>(),
|
||||
textRunIndex: new Map<number, number[]>(),
|
||||
}
|
||||
|
||||
// Inline index build (mirrors materialize.ts logic)
|
||||
const domNodeById = new Map<number, number>()
|
||||
for (let i = 0; i < world.dom.nodeId.length; i++) {
|
||||
domNodeById.set(world.dom.nodeId[i], i)
|
||||
}
|
||||
|
||||
for (let i = 0; i < world.subjects.ids.length; i++) {
|
||||
const subjectId = world.subjects.ids[i]
|
||||
const kind = world.subjects.subjectKind[i]
|
||||
const domNodeId = world.subjects.domNodeId[i]
|
||||
if (kind !== SubjectKind.Element || domNodeId === 0) continue
|
||||
const domIdx = domNodeById.get(domNodeId)
|
||||
if (domIdx === undefined) continue
|
||||
|
||||
const tagName = world.strings.values[world.dom.tagNameStringId[domIdx]]
|
||||
if (tagName) {
|
||||
const sel = tagName.toLowerCase()
|
||||
const list = world.selectorIndex.get(sel) ?? []
|
||||
list.push(subjectId)
|
||||
world.selectorIndex.set(sel, list)
|
||||
}
|
||||
|
||||
const classIds = world.dom.classNameStringIds[domIdx]
|
||||
for (let c = 0; c < classIds.length; c++) {
|
||||
const cn = world.strings.values[classIds[c]]
|
||||
if (cn) {
|
||||
const sel = '.' + cn.toLowerCase()
|
||||
const list = world.selectorIndex.get(sel) ?? []
|
||||
list.push(subjectId)
|
||||
world.selectorIndex.set(sel, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, ids] of world.selectorIndex) {
|
||||
ids.sort((a, b) => a - b)
|
||||
world.selectorIndex.set(key, ids)
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<number, number[]>()
|
||||
for (let i = 0; i < world.dom.nodeId.length; i++) {
|
||||
const parentId = world.dom.parentNodeId[i]
|
||||
if (parentId !== 0) {
|
||||
const arr = childrenByParent.get(parentId) ?? []
|
||||
arr.push(world.dom.nodeId[i])
|
||||
childrenByParent.set(parentId, arr)
|
||||
}
|
||||
}
|
||||
|
||||
const subjectByDomNodeId = new Map<number, number>()
|
||||
for (let i = 0; i < world.subjects.ids.length; i++) {
|
||||
const d = world.subjects.domNodeId[i]
|
||||
if (d !== 0) subjectByDomNodeId.set(d, world.subjects.ids[i])
|
||||
}
|
||||
|
||||
function collectDescendants(domNodeId: number, out: number[], visited?: Set<number>): void {
|
||||
if (visited?.has(domNodeId)) return
|
||||
visited ??= new Set<number>()
|
||||
visited.add(domNodeId)
|
||||
const childDomIds = childrenByParent.get(domNodeId)
|
||||
if (!childDomIds) return
|
||||
for (const childDomId of childDomIds) {
|
||||
const childSubjectId = subjectByDomNodeId.get(childDomId)
|
||||
if (childSubjectId !== undefined) {
|
||||
out.push(childSubjectId)
|
||||
collectDescendants(childDomId, out, visited)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < world.dom.nodeId.length; i++) {
|
||||
const domNodeId = world.dom.nodeId[i]
|
||||
const subjectId = subjectByDomNodeId.get(domNodeId)
|
||||
if (subjectId !== undefined) {
|
||||
const descendants: number[] = []
|
||||
collectDescendants(domNodeId, descendants)
|
||||
descendants.sort((a, b) => a - b)
|
||||
world.ancestorIndex.set(subjectId, descendants)
|
||||
}
|
||||
}
|
||||
|
||||
return world
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getElementsBySelector', () => {
|
||||
it('returns empty array for unknown selector', () => {
|
||||
const world = makeWorldWithElements([{ id: 1, tag: 'div', classes: [], parentNodeId: 0 }])
|
||||
assert.deepStrictEqual(getElementsBySelector(world, '.missing'), [])
|
||||
})
|
||||
|
||||
it('matches tag selectors', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 1, tag: 'div', classes: [], parentNodeId: 0 },
|
||||
{ id: 2, tag: 'button', classes: [], parentNodeId: 0 },
|
||||
{ id: 3, tag: 'div', classes: [], parentNodeId: 0 },
|
||||
])
|
||||
assert.deepStrictEqual(getElementsBySelector(world, 'div'), [1, 3])
|
||||
assert.deepStrictEqual(getElementsBySelector(world, 'button'), [2])
|
||||
})
|
||||
|
||||
it('matches class selectors', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 1, tag: 'div', classes: ['btn'], parentNodeId: 0 },
|
||||
{ id: 2, tag: 'button', classes: ['btn', 'primary'], parentNodeId: 0 },
|
||||
{ id: 3, tag: 'span', classes: [], parentNodeId: 0 },
|
||||
])
|
||||
assert.deepStrictEqual(getElementsBySelector(world, '.btn'), [1, 2])
|
||||
assert.deepStrictEqual(getElementsBySelector(world, '.primary'), [2])
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 1, tag: 'DIV', classes: ['Btn'], parentNodeId: 0 },
|
||||
])
|
||||
assert.deepStrictEqual(getElementsBySelector(world, 'div'), [1])
|
||||
assert.deepStrictEqual(getElementsBySelector(world, '.btn'), [1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDescendants', () => {
|
||||
it('returns empty for leaf nodes', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 1, tag: 'div', classes: [], parentNodeId: 0 },
|
||||
{ id: 2, tag: 'span', classes: [], parentNodeId: 1 },
|
||||
])
|
||||
assert.deepStrictEqual(getDescendants(world, 2), [])
|
||||
})
|
||||
|
||||
it('returns transitive descendants', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 1, tag: 'div', classes: [], parentNodeId: 0 },
|
||||
{ id: 2, tag: 'section', classes: [], parentNodeId: 1 },
|
||||
{ id: 3, tag: 'p', classes: [], parentNodeId: 2 },
|
||||
{ id: 4, tag: 'span', classes: [], parentNodeId: 3 },
|
||||
])
|
||||
assert.deepStrictEqual(getDescendants(world, 1), [2, 3, 4])
|
||||
assert.deepStrictEqual(getDescendants(world, 2), [3, 4])
|
||||
assert.deepStrictEqual(getDescendants(world, 3), [4])
|
||||
})
|
||||
|
||||
it('filters descendants by selector', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 1, tag: 'div', classes: ['card'], parentNodeId: 0 },
|
||||
{ id: 2, tag: 'section', classes: ['header'], parentNodeId: 1 },
|
||||
{ id: 3, tag: 'p', classes: ['content'], parentNodeId: 1 },
|
||||
{ id: 4, tag: 'span', classes: ['content'], parentNodeId: 3 },
|
||||
])
|
||||
assert.deepStrictEqual(getDescendants(world, 1, '.content'), [3, 4])
|
||||
assert.deepStrictEqual(getDescendants(world, 1, 'section'), [2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLineBoxes / getTextRuns', () => {
|
||||
it('returns empty for missing text node', () => {
|
||||
const world = makeWorldWithElements([])
|
||||
assert.deepStrictEqual(getLineBoxes(world, 99), [])
|
||||
assert.deepStrictEqual(getTextRuns(world, 99), [])
|
||||
})
|
||||
|
||||
it('indexes line boxes by text node subject', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 10, tag: 'p', classes: [], parentNodeId: 0 },
|
||||
])
|
||||
world.fragments = {
|
||||
fragmentId: new Uint32Array([100, 101, 102]),
|
||||
subjectId: new Uint32Array([10, 10, 10]),
|
||||
fragmentKind: new Uint16Array([FragmentKind.Line, FragmentKind.Line, FragmentKind.Column]),
|
||||
boxLeft: new Float64Array(3),
|
||||
boxTop: new Float64Array(3),
|
||||
boxRight: new Float64Array(3),
|
||||
boxBottom: new Float64Array(3),
|
||||
lineIndex: new Int32Array(3),
|
||||
flowIndex: new Int32Array(3),
|
||||
parentFragmentId: new Uint32Array(3),
|
||||
}
|
||||
world.lineBoxIndex = new Map()
|
||||
for (let i = 0; i < world.fragments.fragmentId.length; i++) {
|
||||
if (world.fragments.fragmentKind[i] === FragmentKind.Line) {
|
||||
const sid = world.fragments.subjectId[i]
|
||||
const arr = world.lineBoxIndex.get(sid) ?? []
|
||||
arr.push(world.fragments.fragmentId[i])
|
||||
world.lineBoxIndex.set(sid, arr)
|
||||
}
|
||||
}
|
||||
for (const [key, ids] of world.lineBoxIndex) {
|
||||
ids.sort((a, b) => a - b)
|
||||
world.lineBoxIndex.set(key, ids)
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(getLineBoxes(world, 10), [100, 101])
|
||||
assert.deepStrictEqual(getLineBoxes(world, 99), [])
|
||||
})
|
||||
|
||||
it('indexes text runs by text node subject', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 10, tag: 'p', classes: [], parentNodeId: 0 },
|
||||
])
|
||||
world.text = {
|
||||
runId: new Uint32Array([200, 201, 202]),
|
||||
subjectId: new Uint32Array([10, 10, 11]),
|
||||
contentStringId: new Uint32Array(3),
|
||||
lineBoxId: new Uint32Array(3),
|
||||
inkLeft: new Float64Array(3),
|
||||
inkTop: new Float64Array(3),
|
||||
inkRight: new Float64Array(3),
|
||||
inkBottom: new Float64Array(3),
|
||||
baselineY: new Float64Array(3),
|
||||
capHeight: new Float64Array(3),
|
||||
computedLineHeight: new Float64Array(3),
|
||||
}
|
||||
world.textRunIndex = new Map()
|
||||
for (let i = 0; i < world.text.runId.length; i++) {
|
||||
const sid = world.text.subjectId[i]
|
||||
const arr = world.textRunIndex.get(sid) ?? []
|
||||
arr.push(world.text.runId[i])
|
||||
world.textRunIndex.set(sid, arr)
|
||||
}
|
||||
for (const [key, ids] of world.textRunIndex) {
|
||||
ids.sort((a, b) => a - b)
|
||||
world.textRunIndex.set(key, ids)
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(getTextRuns(world, 10), [200, 201])
|
||||
assert.deepStrictEqual(getTextRuns(world, 11), [202])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryDomain', () => {
|
||||
it('resolves element domain refs', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 1, tag: 'button', classes: ['primary'], parentNodeId: 0 },
|
||||
])
|
||||
assert.deepStrictEqual(queryDomain(world, { kind: 'elements', selector: 'button' }), [1])
|
||||
assert.deepStrictEqual(queryDomain(world, { kind: 'elements', selector: '.primary' }), [1])
|
||||
})
|
||||
|
||||
it('resolves descendant domain refs', () => {
|
||||
const world = makeWorldWithElements([
|
||||
{ id: 1, tag: 'div', classes: [], parentNodeId: 0 },
|
||||
{ id: 2, tag: 'span', classes: ['a'], parentNodeId: 1 },
|
||||
{ id: 3, tag: 'span', classes: ['b'], parentNodeId: 1 },
|
||||
])
|
||||
assert.deepStrictEqual(queryDomain(world, { kind: 'descendants', parentId: 1, selector: 'span' }), [2, 3])
|
||||
assert.deepStrictEqual(queryDomain(world, { kind: 'descendants', parentId: 1, selector: '.a' }), [2])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('domain index properties', () => {
|
||||
it('getElementsBySelector returns exactly expected IDs (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.integer({ min: 1, max: 1000 }),
|
||||
tag: fc.string({ minLength: 1, maxLength: 10 }).map(s => s.replace(/[^a-zA-Z]/g, '')),
|
||||
classes: fc.array(fc.string({ minLength: 1, maxLength: 10 }).map(s => s.replace(/[^a-zA-Z]/g, '')), { maxLength: 3 }),
|
||||
parentNodeId: fc.integer({ min: 0, max: 1000 }),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 50 },
|
||||
),
|
||||
(elements) => {
|
||||
// Deduplicate by id — a valid world never has duplicate subject IDs.
|
||||
const seenIds = new Set<number>()
|
||||
const uniqueElements = elements.filter(e => {
|
||||
if (seenIds.has(e.id)) return false
|
||||
seenIds.add(e.id)
|
||||
return true
|
||||
})
|
||||
const world = makeWorldWithElements(uniqueElements)
|
||||
// For each unique tag, assert correctness (skip empty tags/classes)
|
||||
const tagSet = new Map<string, number[]>()
|
||||
const classSet = new Map<string, number[]>()
|
||||
for (const el of uniqueElements) {
|
||||
const tag = el.tag.toLowerCase()
|
||||
if (tag) {
|
||||
const tagList = tagSet.get(tag) ?? []
|
||||
tagList.push(el.id)
|
||||
tagSet.set(tag, tagList)
|
||||
}
|
||||
for (const c of el.classes) {
|
||||
if (!c) continue
|
||||
const cn = '.' + c.toLowerCase()
|
||||
const classList = classSet.get(cn) ?? []
|
||||
classList.push(el.id)
|
||||
classSet.set(cn, classList)
|
||||
}
|
||||
}
|
||||
for (const [tag, expected] of tagSet) {
|
||||
expected.sort((a, b) => a - b)
|
||||
const actual = getElementsBySelector(world, tag)
|
||||
assert.deepStrictEqual(actual, expected, `tag selector ${tag}`)
|
||||
}
|
||||
for (const [cls, expected] of classSet) {
|
||||
expected.sort((a, b) => a - b)
|
||||
const actual = getElementsBySelector(world, cls)
|
||||
assert.deepStrictEqual(actual, expected, `class selector ${cls}`)
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
})
|
||||
|
||||
it('getDescendants returns correct transitive closure (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.integer({ min: 1, max: 200 }),
|
||||
parentNodeId: fc.integer({ min: 0, max: 200 }),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 30 },
|
||||
),
|
||||
(nodes) => {
|
||||
// Build a tree where parentNodeId must refer to an existing node or 0
|
||||
const validNodes: typeof nodes = []
|
||||
const ids = new Set<number>()
|
||||
for (const n of nodes) {
|
||||
if (ids.has(n.id)) continue
|
||||
if (n.parentNodeId !== 0 && !ids.has(n.parentNodeId)) continue
|
||||
validNodes.push(n)
|
||||
ids.add(n.id)
|
||||
}
|
||||
if (validNodes.length === 0) return true
|
||||
|
||||
const elements = validNodes.map(n => ({ id: n.id, tag: 'div', classes: [], parentNodeId: n.parentNodeId }))
|
||||
const world = makeWorldWithElements(elements)
|
||||
|
||||
// Compute expected descendants for each node
|
||||
const childrenMap = new Map<number, number[]>()
|
||||
for (const n of validNodes) {
|
||||
if (n.parentNodeId !== 0) {
|
||||
const arr = childrenMap.get(n.parentNodeId) ?? []
|
||||
arr.push(n.id)
|
||||
childrenMap.set(n.parentNodeId, arr)
|
||||
}
|
||||
}
|
||||
|
||||
function collect(nodeId: number, visited = new Set<number>()): number[] {
|
||||
if (visited.has(nodeId)) return []
|
||||
visited.add(nodeId)
|
||||
const out: number[] = []
|
||||
const childIds = childrenMap.get(nodeId)
|
||||
if (childIds) {
|
||||
for (const c of childIds) {
|
||||
out.push(c)
|
||||
out.push(...collect(c, visited))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
for (const n of validNodes) {
|
||||
const expected = collect(n.id).sort((a, b) => a - b)
|
||||
const actual = getDescendants(world, n.id)
|
||||
assert.deepStrictEqual(actual, expected, `descendants of ${n.id}`)
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
})
|
||||
|
||||
it('closed-world: elements not in the world never appear', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.integer({ min: 1, max: 100 }),
|
||||
tag: fc.string({ minLength: 1, maxLength: 5 }).map(s => s.replace(/[^a-zA-Z]/g, '')),
|
||||
classes: fc.array(fc.string({ minLength: 1, maxLength: 5 }).map(s => s.replace(/[^a-zA-Z]/g, '')), { maxLength: 2 }),
|
||||
parentNodeId: fc.integer({ min: 0, max: 100 }),
|
||||
}),
|
||||
{ minLength: 0, maxLength: 20 },
|
||||
),
|
||||
fc.integer({ min: 101, max: 1000 }),
|
||||
(elements, ghostId) => {
|
||||
// Filter out self-parent references to avoid cycles
|
||||
const validElements = elements.filter(e => e.parentNodeId !== e.id)
|
||||
const world = makeWorldWithElements(validElements)
|
||||
// No selector should return the ghost ID
|
||||
const allSelectors = new Set<string>()
|
||||
for (const el of validElements) {
|
||||
const tag = el.tag.toLowerCase()
|
||||
if (tag) allSelectors.add(tag)
|
||||
for (const c of el.classes) {
|
||||
if (c) allSelectors.add('.' + c.toLowerCase())
|
||||
}
|
||||
}
|
||||
for (const sel of allSelectors) {
|
||||
const ids = getElementsBySelector(world, sel)
|
||||
assert.ok(!ids.includes(ghostId), `ghost ${ghostId} found in ${sel}`)
|
||||
}
|
||||
// Descendants of ghost should be empty
|
||||
assert.deepStrictEqual(getDescendants(world, ghostId), [])
|
||||
// Line boxes and text runs of ghost should be empty
|
||||
assert.deepStrictEqual(getLineBoxes(world, ghostId), [])
|
||||
assert.deepStrictEqual(getTextRuns(world, ghostId), [])
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
// Domain index query APIs for deterministic first-order logic enumeration (V1.1)
|
||||
// All returned arrays are owned by the index — no copies on read.
|
||||
|
||||
import { GeometryWorld } from './world.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selector queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return all element subject IDs that match the given CSS selector string.
|
||||
* Supported selector forms:
|
||||
* - tag name: "div", "button", "span"
|
||||
* - class: ".button", ".card"
|
||||
* Returns a sorted array owned by the world selectorIndex.
|
||||
* If the selector is not indexed, returns an empty array.
|
||||
*/
|
||||
export function getElementsBySelector(world: GeometryWorld, selector: string): number[] {
|
||||
const normalized = selector.trim().toLowerCase()
|
||||
return world.selectorIndex.get(normalized) ?? []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ancestor / descendant queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return all descendant element subject IDs of `parentId`.
|
||||
* If `selector` is provided, filter to those matching the selector.
|
||||
* Returns a sorted array owned by the world ancestorIndex (or a filtered copy).
|
||||
*/
|
||||
export function getDescendants(world: GeometryWorld, parentId: number, selector?: string): number[] {
|
||||
const all = world.ancestorIndex.get(parentId) ?? []
|
||||
if (!selector) {
|
||||
return all
|
||||
}
|
||||
const matched = getElementsBySelector(world, selector)
|
||||
if (matched.length === 0) {
|
||||
return []
|
||||
}
|
||||
// Intersect two sorted arrays without allocating intermediates.
|
||||
const out: number[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < all.length && j < matched.length) {
|
||||
const a = all[i]
|
||||
const b = matched[j]
|
||||
if (a === b) {
|
||||
out.push(a)
|
||||
i++
|
||||
j++
|
||||
} else if (a < b) {
|
||||
i++
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text geometry queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return line box fragment IDs for the given text node subject ID.
|
||||
* Returns a sorted array owned by the world lineBoxIndex.
|
||||
*/
|
||||
export function getLineBoxes(world: GeometryWorld, textNodeId: number): number[] {
|
||||
return world.lineBoxIndex.get(textNodeId) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Return text run IDs for the given text node subject ID.
|
||||
* Returns a sorted array owned by the world textRunIndex.
|
||||
*/
|
||||
export function getTextRuns(world: GeometryWorld, textNodeId: number): number[] {
|
||||
return world.textRunIndex.get(textNodeId) ?? []
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Frame resolution and transform chains for Imhotep geometry world
|
||||
|
||||
import {
|
||||
GeometryWorld,
|
||||
Frames,
|
||||
FrameKind,
|
||||
Matrices,
|
||||
WorldIndex,
|
||||
Rects,
|
||||
} from './world.js'
|
||||
import {
|
||||
IDENTITY_4X4,
|
||||
multiply4x4,
|
||||
transformPoint,
|
||||
invert4x4,
|
||||
buildTransformChain,
|
||||
readMatrix4x4,
|
||||
translateMatrix,
|
||||
} from './transforms.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame tree navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the parent frame ID for a frame at the given index.
|
||||
* Returns 0 if the frame has no parent (root).
|
||||
*/
|
||||
export function getParentFrameId(frames: Frames, index: number): number {
|
||||
return frames.parentFrameId[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk from a frame up to the root, collecting frame indices in order.
|
||||
* The returned array is [frameIndex, parentIndex, grandparentIndex, ..., rootIndex].
|
||||
*/
|
||||
export function walkFrameAncestors(
|
||||
frames: Frames,
|
||||
startIndex: number
|
||||
): number[] {
|
||||
const path: number[] = []
|
||||
let current = startIndex
|
||||
while (current >= 0 && current < frames.frameId.length) {
|
||||
path.push(current)
|
||||
const parentId = frames.parentFrameId[current]
|
||||
if (parentId === 0) break
|
||||
// Find parent index by linear scan (small N in practice, or use index)
|
||||
let found = -1
|
||||
for (let i = 0; i < frames.frameId.length; i++) {
|
||||
if (frames.frameId[i] === parentId) {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (found === -1) break
|
||||
current = found
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame resolution by kind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the viewport frame index. Returns -1 if not found.
|
||||
*/
|
||||
export function resolveViewportFrame(frames: Frames): number {
|
||||
for (let i = 0; i < frames.frameId.length; i++) {
|
||||
if (frames.frameKind[i] === FrameKind.Viewport) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the containing-block frame for a subject.
|
||||
* Uses the topology.containingBlockOf relation.
|
||||
*/
|
||||
export function resolveContainingBlockFrame(
|
||||
world: GeometryWorld,
|
||||
subjectId: number,
|
||||
index: WorldIndex
|
||||
): number {
|
||||
const containingBlockSubjectId = world.topology.containingBlockOf[subjectId]
|
||||
if (containingBlockSubjectId === 0) {
|
||||
return resolveViewportFrame(world.frames)
|
||||
}
|
||||
// Find frame owned by that subject
|
||||
for (let i = 0; i < world.frames.frameId.length; i++) {
|
||||
if (
|
||||
world.frames.ownerSubjectId[i] === containingBlockSubjectId &&
|
||||
world.frames.frameKind[i] === FrameKind.ContainingBlock
|
||||
) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return resolveViewportFrame(world.frames)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the nearest positioned ancestor frame for a subject.
|
||||
*/
|
||||
export function resolveNearestPositionedAncestorFrame(
|
||||
world: GeometryWorld,
|
||||
subjectId: number
|
||||
): number {
|
||||
const ancestorId = world.topology.nearestPositionedAncestorOf[subjectId]
|
||||
if (ancestorId === 0) {
|
||||
return resolveViewportFrame(world.frames)
|
||||
}
|
||||
for (let i = 0; i < world.frames.frameId.length; i++) {
|
||||
if (
|
||||
world.frames.ownerSubjectId[i] === ancestorId &&
|
||||
world.frames.frameKind[i] === FrameKind.NearestPositionedAncestor
|
||||
) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return resolveViewportFrame(world.frames)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the scroll-container frame for a subject.
|
||||
*/
|
||||
export function resolveScrollContainerFrame(
|
||||
world: GeometryWorld,
|
||||
subjectId: number
|
||||
): number {
|
||||
const containerId = world.topology.scrollContainerOf[subjectId]
|
||||
if (containerId === 0) {
|
||||
return -1
|
||||
}
|
||||
for (let i = 0; i < world.frames.frameId.length; i++) {
|
||||
if (
|
||||
world.frames.ownerSubjectId[i] === containerId &&
|
||||
world.frames.frameKind[i] === FrameKind.ScrollContainer
|
||||
) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transform chain construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collect the transform matrices along a frame's ancestor chain,
|
||||
* from root to leaf, so that multiplying them gives the total transform
|
||||
* from the root frame to the target frame.
|
||||
*/
|
||||
export function collectFrameTransformChain(
|
||||
world: GeometryWorld,
|
||||
frameIndex: number,
|
||||
index: WorldIndex
|
||||
): Float64Array[] {
|
||||
const ancestorIndices = walkFrameAncestors(world.frames, frameIndex)
|
||||
// ancestorIndices is [leaf, ..., root]; reverse to get root -> leaf
|
||||
ancestorIndices.reverse()
|
||||
|
||||
const matrices: Float64Array[] = []
|
||||
for (const idx of ancestorIndices) {
|
||||
// Origin translation: frame origin in parent coordinates
|
||||
const originX = world.frames.originX[idx]
|
||||
const originY = world.frames.originY[idx]
|
||||
if (originX !== 0 || originY !== 0) {
|
||||
matrices.push(translateMatrix(originX, originY))
|
||||
}
|
||||
|
||||
const matrixStart = world.frames.axisMatrixStart[idx]
|
||||
if (matrixStart >= 0 && matrixStart < world.matrices.values.length) {
|
||||
const m = readMatrix4x4(
|
||||
world.matrices.values,
|
||||
matrixStart,
|
||||
new Float64Array(16)
|
||||
)
|
||||
matrices.push(m)
|
||||
}
|
||||
}
|
||||
return matrices
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the total 4x4 transform matrix for a frame relative to the root.
|
||||
*/
|
||||
export function buildFrameTransform(
|
||||
world: GeometryWorld,
|
||||
frameIndex: number,
|
||||
index: WorldIndex
|
||||
): Float64Array {
|
||||
const chain = collectFrameTransformChain(world, frameIndex, index)
|
||||
return buildTransformChain(chain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the transform that maps coordinates from frame A to frame B.
|
||||
* Returns a 4x4 matrix such that:
|
||||
* point_in_B = M * point_in_A
|
||||
* The matrix is inverse(B_to_root) * A_to_root.
|
||||
*/
|
||||
export function buildFrameToFrameTransform(
|
||||
world: GeometryWorld,
|
||||
fromFrameIndex: number,
|
||||
toFrameIndex: number,
|
||||
index: WorldIndex
|
||||
): Float64Array | null {
|
||||
const fromTransform = buildFrameTransform(world, fromFrameIndex, index)
|
||||
const toTransform = buildFrameTransform(world, toFrameIndex, index)
|
||||
|
||||
const invTo = invert4x4(toTransform)
|
||||
if (!invTo) return null
|
||||
|
||||
return multiply4x4(invTo, fromTransform)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a point from one frame to another.
|
||||
* Returns null if the transform is singular.
|
||||
*/
|
||||
export function convertPoint(
|
||||
world: GeometryWorld,
|
||||
fromFrameIndex: number,
|
||||
toFrameIndex: number,
|
||||
x: number,
|
||||
y: number,
|
||||
index: WorldIndex
|
||||
): { x: number; y: number } | null {
|
||||
const m = buildFrameToFrameTransform(
|
||||
world,
|
||||
fromFrameIndex,
|
||||
toFrameIndex,
|
||||
index
|
||||
)
|
||||
if (!m) return null
|
||||
const out = transformPoint(m, x, y)
|
||||
return { x: out[0], y: out[1] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a rect from one frame to another.
|
||||
* Transforms all four corners and takes the bounding box.
|
||||
*/
|
||||
export function convertRect(
|
||||
world: GeometryWorld,
|
||||
fromFrameIndex: number,
|
||||
toFrameIndex: number,
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
index: WorldIndex
|
||||
): { left: number; top: number; right: number; bottom: number } | null {
|
||||
const m = buildFrameToFrameTransform(
|
||||
world,
|
||||
fromFrameIndex,
|
||||
toFrameIndex,
|
||||
index
|
||||
)
|
||||
if (!m) return null
|
||||
|
||||
const corners = [
|
||||
{ x: left, y: top },
|
||||
{ x: right, y: top },
|
||||
{ x: right, y: bottom },
|
||||
{ x: left, y: bottom },
|
||||
]
|
||||
|
||||
let minX = Infinity,
|
||||
minY = Infinity
|
||||
let maxX = -Infinity,
|
||||
maxY = -Infinity
|
||||
|
||||
const out = new Float64Array(2)
|
||||
for (const c of corners) {
|
||||
transformPoint(m, c.x, c.y, out)
|
||||
minX = Math.min(minX, out[0])
|
||||
minY = Math.min(minY, out[1])
|
||||
maxX = Math.max(maxX, out[0])
|
||||
maxY = Math.max(maxY, out[1])
|
||||
}
|
||||
|
||||
return { left: minX, top: minY, right: maxX, bottom: maxY }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame origin helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the origin of a frame in its parent frame.
|
||||
*/
|
||||
export function getFrameOrigin(
|
||||
frames: Frames,
|
||||
index: number
|
||||
): { x: number; y: number } {
|
||||
return { x: frames.originX[index], y: frames.originY[index] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full origin of a frame in the root (viewport) coordinate space.
|
||||
*/
|
||||
export function resolveFrameOriginInRoot(
|
||||
world: GeometryWorld,
|
||||
frameIndex: number,
|
||||
index: WorldIndex
|
||||
): { x: number; y: number } {
|
||||
const ancestorIndices = walkFrameAncestors(world.frames, frameIndex)
|
||||
ancestorIndices.reverse() // root -> leaf
|
||||
|
||||
let x = 0
|
||||
let y = 0
|
||||
const tmp = new Float64Array(2)
|
||||
|
||||
// Start at root origin, accumulate transforms and origins
|
||||
for (let i = 0; i < ancestorIndices.length; i++) {
|
||||
const idx = ancestorIndices[i]
|
||||
x += world.frames.originX[idx]
|
||||
y += world.frames.originY[idx]
|
||||
|
||||
if (i > 0) {
|
||||
// Apply previous frame's transform to the accumulated offset
|
||||
const prevIdx = ancestorIndices[i - 1]
|
||||
const matrixStart = world.frames.axisMatrixStart[prevIdx]
|
||||
if (matrixStart >= 0 && matrixStart < world.matrices.values.length) {
|
||||
const m = readMatrix4x4(
|
||||
world.matrices.values,
|
||||
matrixStart,
|
||||
new Float64Array(16)
|
||||
)
|
||||
transformPoint(m, x, y, tmp)
|
||||
x = tmp[0]
|
||||
y = tmp[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// imhotep-geometry — Geometry world materialization for Imhotep
|
||||
// Exports: world schema, normalization, frames, boxes, transforms, materialization, domain indexes
|
||||
|
||||
export * from './world.js'
|
||||
export * from './transforms.js'
|
||||
export * from './frames.js'
|
||||
export * from './boxes.js'
|
||||
export * from './normalize.js'
|
||||
export * from './materialize.js'
|
||||
export * from './domain-index.js'
|
||||
export * from './query-index.js'
|
||||
@@ -0,0 +1,343 @@
|
||||
// World materialization from extraction results
|
||||
// Orchestrates normalization into an immutable geometry world
|
||||
|
||||
import {
|
||||
GeometryWorld,
|
||||
StringTable,
|
||||
Matrices,
|
||||
buildWorldIndex,
|
||||
WorldIndex,
|
||||
} from './world.js'
|
||||
import {
|
||||
RawExtractionResult,
|
||||
normalizeEnv,
|
||||
normalizeSource,
|
||||
normalizeSubjects,
|
||||
normalizeDom,
|
||||
normalizeFrames,
|
||||
normalizeRects,
|
||||
normalizeBoxes,
|
||||
normalizeFragments,
|
||||
normalizeTransforms,
|
||||
normalizeStyles,
|
||||
normalizeText,
|
||||
normalizeTopology,
|
||||
normalizeScroll,
|
||||
normalizeClipping,
|
||||
normalizePaint,
|
||||
normalizeVisibility,
|
||||
normalizeProvenance,
|
||||
normalizeConfidence,
|
||||
} from './normalize.js'
|
||||
import { IDENTITY_4X4 } from './transforms.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Materialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Materialize a geometry world from raw extraction results.
|
||||
* The returned world is immutable and fully indexed.
|
||||
*/
|
||||
export function materializeWorld(raw: RawExtractionResult): GeometryWorld {
|
||||
const strings: StringTable = { values: [] }
|
||||
|
||||
const env = normalizeEnv(raw.env, strings)
|
||||
const source = normalizeSource(raw.source, strings)
|
||||
|
||||
const sceneId = strings.values.indexOf(raw.sceneId)
|
||||
const snapshotId = strings.values.indexOf(raw.snapshotId)
|
||||
|
||||
const subjects = normalizeSubjects(raw.subjects)
|
||||
const dom = normalizeDom(raw.dom, strings)
|
||||
const rects = normalizeRects(raw.rects)
|
||||
const boxes = normalizeBoxes(raw.boxes)
|
||||
const fragments = normalizeFragments(raw.fragments)
|
||||
|
||||
let matrices: Matrices = { values: new Float64Array(0) }
|
||||
|
||||
const framesResult = normalizeFrames(raw.frames, strings, matrices)
|
||||
const frames = framesResult.frames
|
||||
matrices = framesResult.matrices
|
||||
|
||||
const transformsResult = normalizeTransforms(raw.transforms, matrices)
|
||||
const transforms = transformsResult.transforms
|
||||
matrices = transformsResult.matrices
|
||||
|
||||
const styles = normalizeStyles(raw.styles, strings)
|
||||
const text = normalizeText(raw.text, strings)
|
||||
const topology = normalizeTopology(raw.topology, raw.subjects.length)
|
||||
const scroll = normalizeScroll(raw.scroll)
|
||||
const clipping = normalizeClipping(raw.clipping, strings)
|
||||
const paint = normalizePaint(raw.paint)
|
||||
const visibility = normalizeVisibility(raw.visibility)
|
||||
const provenance = normalizeProvenance(raw.provenance, strings)
|
||||
const confidence = normalizeConfidence(raw.confidence, strings)
|
||||
|
||||
const world: GeometryWorld = {
|
||||
sceneId: sceneId >= 0 ? sceneId : strings.values.push(raw.sceneId) - 1,
|
||||
snapshotId: snapshotId >= 0 ? snapshotId : strings.values.push(raw.snapshotId) - 1,
|
||||
env,
|
||||
source,
|
||||
strings,
|
||||
subjects,
|
||||
dom,
|
||||
frames,
|
||||
matrices,
|
||||
rects,
|
||||
boxes,
|
||||
fragments,
|
||||
transforms,
|
||||
styles,
|
||||
text,
|
||||
topology,
|
||||
scroll,
|
||||
clipping,
|
||||
paint,
|
||||
visibility,
|
||||
provenance,
|
||||
confidence,
|
||||
selectorIndex: new Map<string, number[]>(),
|
||||
ancestorIndex: new Map<number, number[]>(),
|
||||
lineBoxIndex: new Map<number, number[]>(),
|
||||
textRunIndex: new Map<number, number[]>(),
|
||||
}
|
||||
|
||||
buildDomainIndexes(world)
|
||||
|
||||
return world
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain index construction (V1.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDomainIndexes(world: GeometryWorld): void {
|
||||
// Build selector index: tag names and class selectors for element subjects.
|
||||
const selectorIndex = world.selectorIndex
|
||||
const domNodeById = new Map<number, number>()
|
||||
for (let i = 0; i < world.dom.nodeId.length; i++) {
|
||||
domNodeById.set(world.dom.nodeId[i], i)
|
||||
}
|
||||
|
||||
for (let i = 0; i < world.subjects.ids.length; i++) {
|
||||
const subjectId = world.subjects.ids[i]
|
||||
const kind = world.subjects.subjectKind[i]
|
||||
const domNodeId = world.subjects.domNodeId[i]
|
||||
if (kind !== 1 /* SubjectKind.Element */ || domNodeId === 0) continue
|
||||
|
||||
const domIdx = domNodeById.get(domNodeId)
|
||||
if (domIdx === undefined) continue
|
||||
|
||||
// Tag selector
|
||||
const tagName = world.strings.values[world.dom.tagNameStringId[domIdx]]
|
||||
if (tagName) {
|
||||
const tagSelector = tagName.toLowerCase()
|
||||
const tagList = selectorIndex.get(tagSelector) ?? []
|
||||
tagList.push(subjectId)
|
||||
selectorIndex.set(tagSelector, tagList)
|
||||
}
|
||||
|
||||
// Class selectors
|
||||
const classIds = world.dom.classNameStringIds[domIdx]
|
||||
for (let c = 0; c < classIds.length; c++) {
|
||||
const className = world.strings.values[classIds[c]]
|
||||
if (className) {
|
||||
const classSelector = '.' + className.toLowerCase()
|
||||
const classList = selectorIndex.get(classSelector) ?? []
|
||||
classList.push(subjectId)
|
||||
selectorIndex.set(classSelector, classList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all selector lists for deterministic enumeration
|
||||
for (const [key, ids] of selectorIndex) {
|
||||
ids.sort((a, b) => a - b)
|
||||
selectorIndex.set(key, ids)
|
||||
}
|
||||
|
||||
// Build ancestor index: element id -> sorted descendant ids (transitive)
|
||||
const ancestorIndex = world.ancestorIndex
|
||||
const childrenByParent = new Map<number, number[]>()
|
||||
for (let i = 0; i < world.dom.nodeId.length; i++) {
|
||||
const parentId = world.dom.parentNodeId[i]
|
||||
if (parentId !== 0) {
|
||||
const arr = childrenByParent.get(parentId) ?? []
|
||||
arr.push(world.dom.nodeId[i])
|
||||
childrenByParent.set(parentId, arr)
|
||||
}
|
||||
}
|
||||
|
||||
// Map DOM node IDs to subject IDs for elements
|
||||
const subjectByDomNodeId = new Map<number, number>()
|
||||
for (let i = 0; i < world.subjects.ids.length; i++) {
|
||||
const domNodeId = world.subjects.domNodeId[i]
|
||||
if (domNodeId !== 0) {
|
||||
subjectByDomNodeId.set(domNodeId, world.subjects.ids[i])
|
||||
}
|
||||
}
|
||||
|
||||
function collectDescendants(domNodeId: number, out: number[]): void {
|
||||
const childDomIds = childrenByParent.get(domNodeId)
|
||||
if (!childDomIds) return
|
||||
for (const childDomId of childDomIds) {
|
||||
const childSubjectId = subjectByDomNodeId.get(childDomId)
|
||||
if (childSubjectId !== undefined) {
|
||||
out.push(childSubjectId)
|
||||
collectDescendants(childDomId, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < world.dom.nodeId.length; i++) {
|
||||
const domNodeId = world.dom.nodeId[i]
|
||||
const subjectId = subjectByDomNodeId.get(domNodeId)
|
||||
if (subjectId !== undefined) {
|
||||
const descendants: number[] = []
|
||||
collectDescendants(domNodeId, descendants)
|
||||
descendants.sort((a, b) => a - b)
|
||||
ancestorIndex.set(subjectId, descendants)
|
||||
}
|
||||
}
|
||||
|
||||
// Build lineBoxIndex: text node subject id -> line box fragment ids
|
||||
const lineBoxIndex = world.lineBoxIndex
|
||||
for (let i = 0; i < world.fragments.fragmentId.length; i++) {
|
||||
if (world.fragments.fragmentKind[i] === 1 /* FragmentKind.Line */) {
|
||||
const subjectId = world.fragments.subjectId[i]
|
||||
const arr = lineBoxIndex.get(subjectId) ?? []
|
||||
arr.push(world.fragments.fragmentId[i])
|
||||
lineBoxIndex.set(subjectId, arr)
|
||||
}
|
||||
}
|
||||
for (const [key, ids] of lineBoxIndex) {
|
||||
ids.sort((a, b) => a - b)
|
||||
lineBoxIndex.set(key, ids)
|
||||
}
|
||||
|
||||
// Build textRunIndex: text node subject id -> text run ids
|
||||
const textRunIndex = world.textRunIndex
|
||||
for (let i = 0; i < world.text.runId.length; i++) {
|
||||
const subjectId = world.text.subjectId[i]
|
||||
const arr = textRunIndex.get(subjectId) ?? []
|
||||
arr.push(world.text.runId[i])
|
||||
textRunIndex.set(subjectId, arr)
|
||||
}
|
||||
for (const [key, ids] of textRunIndex) {
|
||||
ids.sort((a, b) => a - b)
|
||||
textRunIndex.set(key, ids)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a world and build its index in one call.
|
||||
*/
|
||||
export function materializeWorldWithIndex(raw: RawExtractionResult): {
|
||||
world: GeometryWorld
|
||||
index: WorldIndex
|
||||
} {
|
||||
const world = materializeWorld(raw)
|
||||
const index = buildWorldIndex(world)
|
||||
return { world, index }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// World validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorldValidationError {
|
||||
code: string
|
||||
message: string
|
||||
table: string
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a geometry world for structural integrity.
|
||||
* Returns a list of validation errors (empty if valid).
|
||||
*/
|
||||
export function validateWorld(world: GeometryWorld): WorldValidationError[] {
|
||||
const errors: WorldValidationError[] = []
|
||||
|
||||
// Check that subjects have valid domNodeId references
|
||||
for (let i = 0; i < world.subjects.ids.length; i++) {
|
||||
const domNodeId = world.subjects.domNodeId[i]
|
||||
if (domNodeId === 0) continue
|
||||
let found = false
|
||||
for (let j = 0; j < world.dom.nodeId.length; j++) {
|
||||
if (world.dom.nodeId[j] === domNodeId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
errors.push({
|
||||
code: 'INVALID_DOM_REF',
|
||||
message: `Subject ${world.subjects.ids[i]} references missing domNodeId ${domNodeId}`,
|
||||
table: 'subjects',
|
||||
index: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check that boxes have valid subject and frame references
|
||||
for (let i = 0; i < world.boxes.boxId.length; i++) {
|
||||
const subjectId = world.boxes.subjectId[i]
|
||||
let foundSubject = false
|
||||
for (let j = 0; j < world.subjects.ids.length; j++) {
|
||||
if (world.subjects.ids[j] === subjectId) {
|
||||
foundSubject = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!foundSubject) {
|
||||
errors.push({
|
||||
code: 'INVALID_SUBJECT_REF',
|
||||
message: `Box ${world.boxes.boxId[i]} references missing subjectId ${subjectId}`,
|
||||
table: 'boxes',
|
||||
index: i,
|
||||
})
|
||||
}
|
||||
|
||||
const frameId = world.boxes.frameId[i]
|
||||
let foundFrame = false
|
||||
for (let j = 0; j < world.frames.frameId.length; j++) {
|
||||
if (world.frames.frameId[j] === frameId) {
|
||||
foundFrame = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!foundFrame) {
|
||||
errors.push({
|
||||
code: 'INVALID_FRAME_REF',
|
||||
message: `Box ${world.boxes.boxId[i]} references missing frameId ${frameId}`,
|
||||
table: 'boxes',
|
||||
index: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check that frames have valid parent references
|
||||
for (let i = 0; i < world.frames.frameId.length; i++) {
|
||||
const parentId = world.frames.parentFrameId[i]
|
||||
if (parentId === 0) continue
|
||||
let found = false
|
||||
for (let j = 0; j < world.frames.frameId.length; j++) {
|
||||
if (world.frames.frameId[j] === parentId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
errors.push({
|
||||
code: 'INVALID_PARENT_FRAME',
|
||||
message: `Frame ${world.frames.frameId[i]} references missing parentFrameId ${parentId}`,
|
||||
table: 'frames',
|
||||
index: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
// Raw fact normalization for Imhotep geometry world
|
||||
// Converts extraction results into flat, indexed, immutable geometry tables
|
||||
|
||||
import {
|
||||
StringTable,
|
||||
internString,
|
||||
Env,
|
||||
Source,
|
||||
Subjects,
|
||||
SubjectKind,
|
||||
Dom,
|
||||
Frames,
|
||||
FrameKind,
|
||||
Matrices,
|
||||
Rects,
|
||||
Boxes,
|
||||
Fragments,
|
||||
FragmentKind,
|
||||
Transforms,
|
||||
Styles,
|
||||
DisplayValue,
|
||||
PositionValue,
|
||||
OverflowValue,
|
||||
VisibilityValue,
|
||||
TextRuns,
|
||||
Topology,
|
||||
Scroll,
|
||||
Clipping,
|
||||
ClipKind,
|
||||
Paint,
|
||||
Visibility,
|
||||
Provenance,
|
||||
Confidence,
|
||||
GeometryWorld,
|
||||
} from './world.js'
|
||||
import { MATRIX_4X4_STRIDE } from './world.js'
|
||||
import { appendMatrix, IDENTITY_4X4 } from './transforms.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw extraction types (input to normalization)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RawEnv {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
deviceScaleFactor: number
|
||||
colorScheme: string
|
||||
pointer: string
|
||||
hover: string
|
||||
reducedMotion: boolean
|
||||
locale: string
|
||||
writingMode: string
|
||||
}
|
||||
|
||||
export interface RawSource {
|
||||
url: string
|
||||
browserName: string
|
||||
browserVersion: string
|
||||
engine: string
|
||||
extractedAt: number
|
||||
}
|
||||
|
||||
export interface RawSubject {
|
||||
id: number
|
||||
domNodeId: number
|
||||
kind: string
|
||||
primaryBoxId: number
|
||||
firstFragmentId: number
|
||||
fragmentCount: number
|
||||
firstTextRunId: number
|
||||
textRunCount: number
|
||||
}
|
||||
|
||||
export interface RawDomNode {
|
||||
nodeId: number
|
||||
backendNodeId: number
|
||||
parentNodeId: number
|
||||
firstChildIndex: number
|
||||
childCount: number
|
||||
shadowRootKind: string
|
||||
tagName: string
|
||||
classNames: string[]
|
||||
role: string
|
||||
ariaName: string
|
||||
}
|
||||
|
||||
export interface RawFrame {
|
||||
id: number
|
||||
kind: string
|
||||
ownerSubjectId: number
|
||||
parentFrameId: number
|
||||
originX: number
|
||||
originY: number
|
||||
matrix?: number[] // 16 floats, column-major 4x4
|
||||
clipRectId: number
|
||||
scrollContainerId: number
|
||||
writingMode: string
|
||||
}
|
||||
|
||||
export interface RawRect {
|
||||
id: number
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export interface RawBox {
|
||||
id: 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
|
||||
}
|
||||
|
||||
export interface RawFragment {
|
||||
id: number
|
||||
subjectId: number
|
||||
kind: string
|
||||
boxLeft: number
|
||||
boxTop: number
|
||||
boxRight: number
|
||||
boxBottom: number
|
||||
lineIndex: number
|
||||
flowIndex: number
|
||||
parentFragmentId: number
|
||||
}
|
||||
|
||||
export interface RawTransform {
|
||||
id: number
|
||||
subjectId: number
|
||||
matrices: number[][] // array of 16-float arrays
|
||||
originX: number
|
||||
originY: number
|
||||
}
|
||||
|
||||
export interface RawStyle {
|
||||
subjectId: number
|
||||
display: string
|
||||
position: string
|
||||
zIndex: string | number
|
||||
overflowX: string
|
||||
overflowY: string
|
||||
opacity: number
|
||||
visibility: string
|
||||
contain: string
|
||||
pointerEvents: string
|
||||
lineHeight: number
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
fontWeight: number
|
||||
}
|
||||
|
||||
export interface RawTextRun {
|
||||
id: number
|
||||
subjectId: number
|
||||
content: string
|
||||
lineBoxId: number
|
||||
inkLeft: number
|
||||
inkTop: number
|
||||
inkRight: number
|
||||
inkBottom: number
|
||||
baselineY: number
|
||||
capHeight: number
|
||||
computedLineHeight: number
|
||||
}
|
||||
|
||||
export interface RawTopology {
|
||||
containingBlockOf: number[]
|
||||
nearestPositionedAncestorOf: number[]
|
||||
scrollContainerOf: number[]
|
||||
stackingContextOf: number[]
|
||||
formattingContextOf: number[]
|
||||
clippingRootOf: number[]
|
||||
paintOrderBucket: number[]
|
||||
paintOrderIndex: number[]
|
||||
}
|
||||
|
||||
export interface RawScroll {
|
||||
containerId: number
|
||||
scrollLeft: number
|
||||
scrollTop: number
|
||||
scrollWidth: number
|
||||
scrollHeight: number
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
}
|
||||
|
||||
export interface RawClip {
|
||||
nodeId: number
|
||||
subjectId: number
|
||||
kind: string
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
parentNodeId: number
|
||||
}
|
||||
|
||||
export interface RawPaint {
|
||||
nodeId: number
|
||||
subjectId: number
|
||||
stackingContextId: number
|
||||
bucket: number
|
||||
localPaintIndex: number
|
||||
}
|
||||
|
||||
export interface RawVisibility {
|
||||
subjectId: number
|
||||
isRendered: boolean
|
||||
isVisible: boolean
|
||||
visibleArea: number
|
||||
clippedArea: number
|
||||
}
|
||||
|
||||
export interface RawProvenance {
|
||||
factId: number
|
||||
extractionStepId: number
|
||||
sourceKind: string
|
||||
sourceRef: number
|
||||
}
|
||||
|
||||
export interface RawConfidence {
|
||||
factId: number
|
||||
confidence: number
|
||||
reasonCode: string
|
||||
}
|
||||
|
||||
export interface RawExtractionResult {
|
||||
sceneId: string
|
||||
snapshotId: string
|
||||
env: RawEnv
|
||||
source: RawSource
|
||||
subjects: RawSubject[]
|
||||
dom: RawDomNode[]
|
||||
frames: RawFrame[]
|
||||
rects: RawRect[]
|
||||
boxes: RawBox[]
|
||||
fragments: RawFragment[]
|
||||
transforms: RawTransform[]
|
||||
styles: RawStyle[]
|
||||
text: RawTextRun[]
|
||||
topology: RawTopology
|
||||
scroll: RawScroll[]
|
||||
clipping: RawClip[]
|
||||
paint: RawPaint[]
|
||||
visibility: RawVisibility[]
|
||||
provenance: RawProvenance[]
|
||||
confidence: RawConfidence[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enum mappings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUBJECT_KIND_MAP: Record<string, SubjectKind> = {
|
||||
element: SubjectKind.Element,
|
||||
pseudoElement: SubjectKind.PseudoElement,
|
||||
textRange: SubjectKind.TextRange,
|
||||
lineBox: SubjectKind.LineBox,
|
||||
fragmentBox: SubjectKind.FragmentBox,
|
||||
landmark: SubjectKind.Landmark,
|
||||
gridArea: SubjectKind.GridArea,
|
||||
scrollContainer: SubjectKind.ScrollContainer,
|
||||
syntheticFrame: SubjectKind.SyntheticFrame,
|
||||
}
|
||||
|
||||
const FRAME_KIND_MAP: Record<string, FrameKind> = {
|
||||
viewport: FrameKind.Viewport,
|
||||
containingBlock: FrameKind.ContainingBlock,
|
||||
nearestPositionedAncestor: FrameKind.NearestPositionedAncestor,
|
||||
scrollContainer: FrameKind.ScrollContainer,
|
||||
synthetic: FrameKind.Synthetic,
|
||||
}
|
||||
|
||||
const FRAGMENT_KIND_MAP: Record<string, FragmentKind> = {
|
||||
line: FragmentKind.Line,
|
||||
column: FragmentKind.Column,
|
||||
region: FragmentKind.Region,
|
||||
page: FragmentKind.Page,
|
||||
}
|
||||
|
||||
const DISPLAY_MAP: Record<string, DisplayValue> = {
|
||||
block: DisplayValue.Block,
|
||||
inline: DisplayValue.Inline,
|
||||
'inline-block': DisplayValue.InlineBlock,
|
||||
flex: DisplayValue.Flex,
|
||||
grid: DisplayValue.Grid,
|
||||
none: DisplayValue.None,
|
||||
contents: DisplayValue.Contents,
|
||||
}
|
||||
|
||||
const POSITION_MAP: Record<string, PositionValue> = {
|
||||
static: PositionValue.Static,
|
||||
relative: PositionValue.Relative,
|
||||
absolute: PositionValue.Absolute,
|
||||
fixed: PositionValue.Fixed,
|
||||
sticky: PositionValue.Sticky,
|
||||
}
|
||||
|
||||
const OVERFLOW_MAP: Record<string, OverflowValue> = {
|
||||
visible: OverflowValue.Visible,
|
||||
hidden: OverflowValue.Hidden,
|
||||
scroll: OverflowValue.Scroll,
|
||||
auto: OverflowValue.Auto,
|
||||
clip: OverflowValue.Clip,
|
||||
}
|
||||
|
||||
const VISIBILITY_MAP: Record<string, VisibilityValue> = {
|
||||
visible: VisibilityValue.Visible,
|
||||
hidden: VisibilityValue.Hidden,
|
||||
collapse: VisibilityValue.Collapse,
|
||||
}
|
||||
|
||||
const CLIP_KIND_MAP: Record<string, ClipKind> = {
|
||||
overflow: ClipKind.Overflow,
|
||||
clipPath: ClipKind.ClipPath,
|
||||
mask: ClipKind.Mask,
|
||||
svgClip: ClipKind.SvgClip,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalization functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function normalizeEnv(raw: RawEnv, strings: StringTable): Env {
|
||||
return {
|
||||
viewportWidth: raw.viewportWidth,
|
||||
viewportHeight: raw.viewportHeight,
|
||||
deviceScaleFactor: raw.deviceScaleFactor,
|
||||
colorScheme: internString(strings, raw.colorScheme),
|
||||
pointer: internString(strings, raw.pointer),
|
||||
hover: internString(strings, raw.hover),
|
||||
reducedMotion: raw.reducedMotion,
|
||||
locale: internString(strings, raw.locale),
|
||||
writingMode: internString(strings, raw.writingMode),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSource(raw: RawSource, strings: StringTable): Source {
|
||||
return {
|
||||
url: internString(strings, raw.url),
|
||||
browserName: internString(strings, raw.browserName),
|
||||
browserVersion: internString(strings, raw.browserVersion),
|
||||
engine: internString(strings, raw.engine),
|
||||
extractedAt: raw.extractedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSubjects(raw: RawSubject[]): Subjects {
|
||||
const n = raw.length
|
||||
const ids = new Uint32Array(n)
|
||||
const domNodeId = new Uint32Array(n)
|
||||
const subjectKind = new Uint16Array(n)
|
||||
const primaryBoxId = new Uint32Array(n)
|
||||
const firstFragmentId = new Uint32Array(n)
|
||||
const fragmentCount = new Uint16Array(n)
|
||||
const firstTextRunId = new Uint32Array(n)
|
||||
const textRunCount = new Uint16Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = raw[i]
|
||||
ids[i] = s.id
|
||||
domNodeId[i] = s.domNodeId
|
||||
subjectKind[i] = SUBJECT_KIND_MAP[s.kind] ?? SubjectKind.Element
|
||||
primaryBoxId[i] = s.primaryBoxId
|
||||
firstFragmentId[i] = s.firstFragmentId
|
||||
fragmentCount[i] = s.fragmentCount
|
||||
firstTextRunId[i] = s.firstTextRunId
|
||||
textRunCount[i] = s.textRunCount
|
||||
}
|
||||
|
||||
return { ids, domNodeId, subjectKind, primaryBoxId, firstFragmentId, fragmentCount, firstTextRunId, textRunCount }
|
||||
}
|
||||
|
||||
export function normalizeDom(raw: RawDomNode[], strings: StringTable): Dom {
|
||||
const n = raw.length
|
||||
const nodeId = new Uint32Array(n)
|
||||
const backendNodeId = new Uint32Array(n)
|
||||
const parentNodeId = new Uint32Array(n)
|
||||
const firstChildIndex = new Uint32Array(n)
|
||||
const childCount = new Uint16Array(n)
|
||||
const shadowRootKind = new Uint8Array(n)
|
||||
const tagNameStringId = new Uint32Array(n)
|
||||
const classNameStringIds: Uint32Array[] = new Array(n)
|
||||
const roleStringId = new Uint32Array(n)
|
||||
const ariaNameStringId = new Uint32Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = raw[i]
|
||||
nodeId[i] = d.nodeId
|
||||
backendNodeId[i] = d.backendNodeId
|
||||
parentNodeId[i] = d.parentNodeId
|
||||
firstChildIndex[i] = d.firstChildIndex
|
||||
childCount[i] = d.childCount
|
||||
shadowRootKind[i] = d.shadowRootKind === 'open' ? 1 : d.shadowRootKind === 'closed' ? 2 : 0
|
||||
tagNameStringId[i] = internString(strings, d.tagName)
|
||||
const classIds = new Uint32Array(d.classNames.length)
|
||||
for (let c = 0; c < d.classNames.length; c++) {
|
||||
classIds[c] = internString(strings, d.classNames[c])
|
||||
}
|
||||
classNameStringIds[i] = classIds
|
||||
roleStringId[i] = internString(strings, d.role)
|
||||
ariaNameStringId[i] = internString(strings, d.ariaName)
|
||||
}
|
||||
|
||||
return { nodeId, backendNodeId, parentNodeId, firstChildIndex, childCount, shadowRootKind, tagNameStringId, classNameStringIds, roleStringId, ariaNameStringId }
|
||||
}
|
||||
|
||||
export function normalizeFrames(
|
||||
raw: RawFrame[],
|
||||
strings: StringTable,
|
||||
matrices: Matrices
|
||||
): { frames: Frames; matrices: Matrices } {
|
||||
const n = raw.length
|
||||
const frameId = new Uint32Array(n)
|
||||
const frameKind = new Uint16Array(n)
|
||||
const ownerSubjectId = new Uint32Array(n)
|
||||
const parentFrameId = new Uint32Array(n)
|
||||
const originX = new Float64Array(n)
|
||||
const originY = new Float64Array(n)
|
||||
const axisMatrixStart = new Int32Array(n)
|
||||
const clipRectId = new Uint32Array(n)
|
||||
const scrollContainerId = new Uint32Array(n)
|
||||
const writingMode = new Uint16Array(n)
|
||||
|
||||
let matBuf = matrices.values
|
||||
let matUsed = matrices.values.length
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = raw[i]
|
||||
frameId[i] = f.id
|
||||
frameKind[i] = FRAME_KIND_MAP[f.kind] ?? FrameKind.Synthetic
|
||||
ownerSubjectId[i] = f.ownerSubjectId
|
||||
parentFrameId[i] = f.parentFrameId
|
||||
originX[i] = f.originX
|
||||
originY[i] = f.originY
|
||||
clipRectId[i] = f.clipRectId
|
||||
scrollContainerId[i] = f.scrollContainerId
|
||||
writingMode[i] = internString(strings, f.writingMode)
|
||||
|
||||
if (f.matrix && f.matrix.length === 16) {
|
||||
const result = appendMatrix(matBuf, matUsed, f.matrix)
|
||||
matBuf = result.buffer
|
||||
matUsed = result.used
|
||||
axisMatrixStart[i] = result.start
|
||||
} else {
|
||||
axisMatrixStart[i] = -1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frames: {
|
||||
frameId,
|
||||
frameKind,
|
||||
ownerSubjectId,
|
||||
parentFrameId,
|
||||
originX,
|
||||
originY,
|
||||
axisMatrixStart,
|
||||
clipRectId,
|
||||
scrollContainerId,
|
||||
writingMode,
|
||||
},
|
||||
matrices: { values: matBuf.subarray(0, matUsed) },
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRects(raw: RawRect[]): Rects {
|
||||
const n = raw.length
|
||||
const rectId = new Uint32Array(n)
|
||||
const left = new Float64Array(n)
|
||||
const top = new Float64Array(n)
|
||||
const right = new Float64Array(n)
|
||||
const bottom = new Float64Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const r = raw[i]
|
||||
rectId[i] = r.id
|
||||
left[i] = r.left
|
||||
top[i] = r.top
|
||||
right[i] = r.right
|
||||
bottom[i] = r.bottom
|
||||
}
|
||||
|
||||
return { rectId, left, top, right, bottom }
|
||||
}
|
||||
|
||||
export function normalizeBoxes(raw: RawBox[]): Boxes {
|
||||
const n = raw.length
|
||||
const boxId = new Uint32Array(n)
|
||||
const subjectId = new Uint32Array(n)
|
||||
const frameId = new Uint32Array(n)
|
||||
const borderLeft = new Float64Array(n)
|
||||
const borderTop = new Float64Array(n)
|
||||
const borderRight = new Float64Array(n)
|
||||
const borderBottom = new Float64Array(n)
|
||||
const paddingLeft = new Float64Array(n)
|
||||
const paddingTop = new Float64Array(n)
|
||||
const paddingRight = new Float64Array(n)
|
||||
const paddingBottom = new Float64Array(n)
|
||||
const contentLeft = new Float64Array(n)
|
||||
const contentTop = new Float64Array(n)
|
||||
const contentRight = new Float64Array(n)
|
||||
const contentBottom = new Float64Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = raw[i]
|
||||
boxId[i] = b.id
|
||||
subjectId[i] = b.subjectId
|
||||
frameId[i] = b.frameId
|
||||
borderLeft[i] = b.borderLeft
|
||||
borderTop[i] = b.borderTop
|
||||
borderRight[i] = b.borderRight
|
||||
borderBottom[i] = b.borderBottom
|
||||
paddingLeft[i] = b.paddingLeft
|
||||
paddingTop[i] = b.paddingTop
|
||||
paddingRight[i] = b.paddingRight
|
||||
paddingBottom[i] = b.paddingBottom
|
||||
contentLeft[i] = b.contentLeft
|
||||
contentTop[i] = b.contentTop
|
||||
contentRight[i] = b.contentRight
|
||||
contentBottom[i] = b.contentBottom
|
||||
}
|
||||
|
||||
return { boxId, subjectId, frameId, borderLeft, borderTop, borderRight, borderBottom, paddingLeft, paddingTop, paddingRight, paddingBottom, contentLeft, contentTop, contentRight, contentBottom }
|
||||
}
|
||||
|
||||
export function normalizeFragments(raw: RawFragment[]): Fragments {
|
||||
const n = raw.length
|
||||
const fragmentId = new Uint32Array(n)
|
||||
const subjectId = new Uint32Array(n)
|
||||
const fragmentKind = new Uint16Array(n)
|
||||
const boxLeft = new Float64Array(n)
|
||||
const boxTop = new Float64Array(n)
|
||||
const boxRight = new Float64Array(n)
|
||||
const boxBottom = new Float64Array(n)
|
||||
const lineIndex = new Int32Array(n)
|
||||
const flowIndex = new Int32Array(n)
|
||||
const parentFragmentId = new Uint32Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = raw[i]
|
||||
fragmentId[i] = f.id
|
||||
subjectId[i] = f.subjectId
|
||||
fragmentKind[i] = FRAGMENT_KIND_MAP[f.kind] ?? FragmentKind.Line
|
||||
boxLeft[i] = f.boxLeft
|
||||
boxTop[i] = f.boxTop
|
||||
boxRight[i] = f.boxRight
|
||||
boxBottom[i] = f.boxBottom
|
||||
lineIndex[i] = f.lineIndex
|
||||
flowIndex[i] = f.flowIndex
|
||||
parentFragmentId[i] = f.parentFragmentId
|
||||
}
|
||||
|
||||
return { fragmentId, subjectId, fragmentKind, boxLeft, boxTop, boxRight, boxBottom, lineIndex, flowIndex, parentFragmentId }
|
||||
}
|
||||
|
||||
export function normalizeTransforms(
|
||||
raw: RawTransform[],
|
||||
matrices: Matrices
|
||||
): { transforms: Transforms; matrices: Matrices } {
|
||||
const n = raw.length
|
||||
const transformId = new Uint32Array(n)
|
||||
const subjectId = new Uint32Array(n)
|
||||
const matrixStart = new Int32Array(n)
|
||||
const matrixLength = new Uint16Array(n)
|
||||
const originX = new Float64Array(n)
|
||||
const originY = new Float64Array(n)
|
||||
|
||||
let matBuf = matrices.values
|
||||
let matUsed = matrices.values.length
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = raw[i]
|
||||
transformId[i] = t.id
|
||||
subjectId[i] = t.subjectId
|
||||
originX[i] = t.originX
|
||||
originY[i] = t.originY
|
||||
|
||||
if (t.matrices && t.matrices.length > 0) {
|
||||
const start = matUsed
|
||||
for (const m of t.matrices) {
|
||||
const result = appendMatrix(matBuf, matUsed, m)
|
||||
matBuf = result.buffer
|
||||
matUsed = result.used
|
||||
}
|
||||
matrixStart[i] = start
|
||||
matrixLength[i] = t.matrices.length
|
||||
} else {
|
||||
matrixStart[i] = -1
|
||||
matrixLength[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transforms: { transformId, subjectId, matrixStart, matrixLength, originX, originY },
|
||||
matrices: { values: matBuf.subarray(0, matUsed) },
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeStyles(raw: RawStyle[], strings: StringTable): Styles {
|
||||
const n = raw.length
|
||||
const subjectId = new Uint32Array(n)
|
||||
const display = new Uint16Array(n)
|
||||
const position = new Uint16Array(n)
|
||||
const zIndexKind = new Uint8Array(n)
|
||||
const zIndexValue = new Int32Array(n)
|
||||
const overflowX = new Uint16Array(n)
|
||||
const overflowY = new Uint16Array(n)
|
||||
const opacity = new Float32Array(n)
|
||||
const visibility = new Uint16Array(n)
|
||||
const containFlags = new Uint32Array(n)
|
||||
const pointerEvents = new Uint16Array(n)
|
||||
const lineHeight = new Float64Array(n)
|
||||
const fontFamilyStringId = new Uint32Array(n)
|
||||
const fontSize = new Float64Array(n)
|
||||
const fontWeight = new Uint16Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = raw[i]
|
||||
subjectId[i] = s.subjectId
|
||||
display[i] = DISPLAY_MAP[s.display] ?? DisplayValue.Block
|
||||
position[i] = POSITION_MAP[s.position] ?? PositionValue.Static
|
||||
|
||||
if (typeof s.zIndex === 'number') {
|
||||
zIndexKind[i] = 1 // explicit integer
|
||||
zIndexValue[i] = s.zIndex
|
||||
} else if (s.zIndex === 'auto') {
|
||||
zIndexKind[i] = 0
|
||||
zIndexValue[i] = 0
|
||||
} else {
|
||||
zIndexKind[i] = 0
|
||||
zIndexValue[i] = 0
|
||||
}
|
||||
|
||||
overflowX[i] = OVERFLOW_MAP[s.overflowX] ?? OverflowValue.Visible
|
||||
overflowY[i] = OVERFLOW_MAP[s.overflowY] ?? OverflowValue.Visible
|
||||
opacity[i] = s.opacity
|
||||
visibility[i] = VISIBILITY_MAP[s.visibility] ?? VisibilityValue.Visible
|
||||
containFlags[i] = 0 // TODO: parse contain values
|
||||
pointerEvents[i] = internString(strings, s.pointerEvents)
|
||||
lineHeight[i] = s.lineHeight
|
||||
fontFamilyStringId[i] = internString(strings, s.fontFamily)
|
||||
fontSize[i] = s.fontSize
|
||||
fontWeight[i] = s.fontWeight
|
||||
}
|
||||
|
||||
return { subjectId, display, position, zIndexKind, zIndexValue, overflowX, overflowY, opacity, visibility, containFlags, pointerEvents, lineHeight, fontFamilyStringId, fontSize, fontWeight }
|
||||
}
|
||||
|
||||
export function normalizeText(raw: RawTextRun[], strings: StringTable): TextRuns {
|
||||
const n = raw.length
|
||||
const runId = new Uint32Array(n)
|
||||
const subjectId = new Uint32Array(n)
|
||||
const contentStringId = new Uint32Array(n)
|
||||
const lineBoxId = new Uint32Array(n)
|
||||
const inkLeft = new Float64Array(n)
|
||||
const inkTop = new Float64Array(n)
|
||||
const inkRight = new Float64Array(n)
|
||||
const inkBottom = new Float64Array(n)
|
||||
const baselineY = new Float64Array(n)
|
||||
const capHeight = new Float64Array(n)
|
||||
const computedLineHeight = new Float64Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = raw[i]
|
||||
runId[i] = t.id
|
||||
subjectId[i] = t.subjectId
|
||||
contentStringId[i] = internString(strings, t.content)
|
||||
lineBoxId[i] = t.lineBoxId
|
||||
inkLeft[i] = t.inkLeft
|
||||
inkTop[i] = t.inkTop
|
||||
inkRight[i] = t.inkRight
|
||||
inkBottom[i] = t.inkBottom
|
||||
baselineY[i] = t.baselineY
|
||||
capHeight[i] = t.capHeight
|
||||
computedLineHeight[i] = t.computedLineHeight
|
||||
}
|
||||
|
||||
return { runId, subjectId, contentStringId, lineBoxId, inkLeft, inkTop, inkRight, inkBottom, baselineY, capHeight, computedLineHeight }
|
||||
}
|
||||
|
||||
export function normalizeTopology(raw: RawTopology, subjectCount: number): Topology {
|
||||
// Avoid intermediate Array.fill allocations — Uint32Array/Uint16Array constructors
|
||||
// zero-initialize automatically when given a length.
|
||||
return {
|
||||
containingBlockOf: raw.containingBlockOf.length > 0
|
||||
? new Uint32Array(raw.containingBlockOf)
|
||||
: new Uint32Array(subjectCount),
|
||||
nearestPositionedAncestorOf: raw.nearestPositionedAncestorOf.length > 0
|
||||
? new Uint32Array(raw.nearestPositionedAncestorOf)
|
||||
: new Uint32Array(subjectCount),
|
||||
scrollContainerOf: raw.scrollContainerOf.length > 0
|
||||
? new Uint32Array(raw.scrollContainerOf)
|
||||
: new Uint32Array(subjectCount),
|
||||
stackingContextOf: raw.stackingContextOf.length > 0
|
||||
? new Uint32Array(raw.stackingContextOf)
|
||||
: new Uint32Array(subjectCount),
|
||||
formattingContextOf: raw.formattingContextOf.length > 0
|
||||
? new Uint32Array(raw.formattingContextOf)
|
||||
: new Uint32Array(subjectCount),
|
||||
clippingRootOf: raw.clippingRootOf.length > 0
|
||||
? new Uint32Array(raw.clippingRootOf)
|
||||
: new Uint32Array(subjectCount),
|
||||
paintOrderBucket: raw.paintOrderBucket.length > 0
|
||||
? new Uint16Array(raw.paintOrderBucket)
|
||||
: new Uint16Array(subjectCount),
|
||||
paintOrderIndex: raw.paintOrderIndex.length > 0
|
||||
? new Uint32Array(raw.paintOrderIndex)
|
||||
: new Uint32Array(subjectCount),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeScroll(raw: RawScroll[]): Scroll {
|
||||
const n = raw.length
|
||||
const containerId = new Uint32Array(n)
|
||||
const scrollLeft = new Float64Array(n)
|
||||
const scrollTop = new Float64Array(n)
|
||||
const scrollWidth = new Float64Array(n)
|
||||
const scrollHeight = new Float64Array(n)
|
||||
const clientWidth = new Float64Array(n)
|
||||
const clientHeight = new Float64Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = raw[i]
|
||||
containerId[i] = s.containerId
|
||||
scrollLeft[i] = s.scrollLeft
|
||||
scrollTop[i] = s.scrollTop
|
||||
scrollWidth[i] = s.scrollWidth
|
||||
scrollHeight[i] = s.scrollHeight
|
||||
clientWidth[i] = s.clientWidth
|
||||
clientHeight[i] = s.clientHeight
|
||||
}
|
||||
|
||||
return { containerId, scrollLeft, scrollTop, scrollWidth, scrollHeight, clientWidth, clientHeight }
|
||||
}
|
||||
|
||||
export function normalizeClipping(raw: RawClip[], strings: StringTable): Clipping {
|
||||
const n = raw.length
|
||||
const clipNodeId = new Uint32Array(n)
|
||||
const subjectId = new Uint32Array(n)
|
||||
const clipKind = new Uint16Array(n)
|
||||
const clipLeft = new Float64Array(n)
|
||||
const clipTop = new Float64Array(n)
|
||||
const clipRight = new Float64Array(n)
|
||||
const clipBottom = new Float64Array(n)
|
||||
const parentClipNodeId = new Uint32Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = raw[i]
|
||||
clipNodeId[i] = c.nodeId
|
||||
subjectId[i] = c.subjectId
|
||||
clipKind[i] = CLIP_KIND_MAP[c.kind] ?? ClipKind.Overflow
|
||||
clipLeft[i] = c.left
|
||||
clipTop[i] = c.top
|
||||
clipRight[i] = c.right
|
||||
clipBottom[i] = c.bottom
|
||||
parentClipNodeId[i] = c.parentNodeId
|
||||
}
|
||||
|
||||
return { clipNodeId, subjectId, clipKind, clipLeft, clipTop, clipRight, clipBottom, parentClipNodeId }
|
||||
}
|
||||
|
||||
export function normalizePaint(raw: RawPaint[]): Paint {
|
||||
const n = raw.length
|
||||
const paintNodeId = new Uint32Array(n)
|
||||
const subjectId = new Uint32Array(n)
|
||||
const stackingContextId = new Uint32Array(n)
|
||||
const bucket = new Uint16Array(n)
|
||||
const localPaintIndex = new Uint32Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = raw[i]
|
||||
paintNodeId[i] = p.nodeId
|
||||
subjectId[i] = p.subjectId
|
||||
stackingContextId[i] = p.stackingContextId
|
||||
bucket[i] = p.bucket
|
||||
localPaintIndex[i] = p.localPaintIndex
|
||||
}
|
||||
|
||||
return { paintNodeId, subjectId, stackingContextId, bucket, localPaintIndex }
|
||||
}
|
||||
|
||||
export function normalizeVisibility(raw: RawVisibility[]): Visibility {
|
||||
const n = raw.length
|
||||
const subjectId = new Uint32Array(n)
|
||||
const isRendered = new Uint8Array(n)
|
||||
const isVisible = new Uint8Array(n)
|
||||
const visibleArea = new Float64Array(n)
|
||||
const clippedArea = new Float64Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = raw[i]
|
||||
subjectId[i] = v.subjectId
|
||||
isRendered[i] = v.isRendered ? 1 : 0
|
||||
isVisible[i] = v.isVisible ? 1 : 0
|
||||
visibleArea[i] = v.visibleArea
|
||||
clippedArea[i] = v.clippedArea
|
||||
}
|
||||
|
||||
return { subjectId, isRendered, isVisible, visibleArea, clippedArea }
|
||||
}
|
||||
|
||||
export function normalizeProvenance(raw: RawProvenance[], strings: StringTable): Provenance {
|
||||
const n = raw.length
|
||||
const factId = new Uint32Array(n)
|
||||
const extractionStepId = new Uint32Array(n)
|
||||
const sourceKind = new Uint16Array(n)
|
||||
const sourceRef = new Uint32Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = raw[i]
|
||||
factId[i] = p.factId
|
||||
extractionStepId[i] = p.extractionStepId
|
||||
sourceKind[i] = internString(strings, p.sourceKind)
|
||||
sourceRef[i] = p.sourceRef
|
||||
}
|
||||
|
||||
return { factId, extractionStepId, sourceKind, sourceRef }
|
||||
}
|
||||
|
||||
export function normalizeConfidence(raw: RawConfidence[], strings: StringTable): Confidence {
|
||||
const n = raw.length
|
||||
const factId = new Uint32Array(n)
|
||||
const confidence = new Float32Array(n)
|
||||
const reasonCode = new Uint16Array(n)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = raw[i]
|
||||
factId[i] = c.factId
|
||||
confidence[i] = c.confidence
|
||||
reasonCode[i] = internString(strings, c.reasonCode)
|
||||
}
|
||||
|
||||
return { factId, confidence, reasonCode }
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fc from 'fast-check';
|
||||
|
||||
import {
|
||||
GeometryWorld,
|
||||
StringTable,
|
||||
FrameKind,
|
||||
buildWorldIndex,
|
||||
internString,
|
||||
} from './world.js';
|
||||
|
||||
import {
|
||||
multiply4x4,
|
||||
translateMatrix,
|
||||
scaleMatrix,
|
||||
rotateMatrix,
|
||||
transformPoint,
|
||||
invert4x4,
|
||||
buildTransformChain,
|
||||
IDENTITY_4X4,
|
||||
} from './transforms.js';
|
||||
|
||||
import {
|
||||
convertPoint,
|
||||
} from './frames.js';
|
||||
|
||||
import {
|
||||
rectIntersection,
|
||||
rectsIntersect,
|
||||
} from './boxes.js';
|
||||
|
||||
// ---- Arbitraries ----
|
||||
|
||||
const finiteFloatArb = fc.integer({ min: -1000, max: 1000 });
|
||||
const positiveFloatArb = fc.float({ noNaN: true, noDefaultInfinity: true, min: Math.fround(0.1), max: Math.fround(100) });
|
||||
|
||||
const matrixArb = fc.record({
|
||||
tx: finiteFloatArb,
|
||||
ty: finiteFloatArb,
|
||||
sx: positiveFloatArb,
|
||||
sy: positiveFloatArb,
|
||||
angle: fc.float({ noNaN: true, noDefaultInfinity: true, min: Math.fround(0), max: Math.fround(Math.PI * 2) }),
|
||||
}).map(({ tx, ty, sx, sy, angle }) => {
|
||||
const t = translateMatrix(tx, ty);
|
||||
const r = rotateMatrix(angle);
|
||||
const s = scaleMatrix(sx, sy);
|
||||
const tr = multiply4x4(t, r);
|
||||
return multiply4x4(tr, s);
|
||||
});
|
||||
|
||||
const rectArb = fc.tuple(finiteFloatArb, finiteFloatArb, finiteFloatArb, finiteFloatArb)
|
||||
.filter(([l, t, r, b]) => l < r && t < b)
|
||||
.map(([l, t, r, b]) => ({ left: l, top: t, right: r, bottom: b }));
|
||||
|
||||
const stringArb = fc.string({ minLength: 1, maxLength: 50 });
|
||||
|
||||
// ---- Property Tests ----
|
||||
|
||||
describe('geometry property tests', () => {
|
||||
it('property: frame transform composition is associative', () => {
|
||||
fc.assert(
|
||||
fc.property(matrixArb, matrixArb, matrixArb, (a, b, c) => {
|
||||
const ab = multiply4x4(a, b);
|
||||
const abc1 = multiply4x4(ab, c);
|
||||
|
||||
const bc = multiply4x4(b, c);
|
||||
const abc2 = multiply4x4(a, bc);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
assert.ok(
|
||||
Math.abs(abc1[i] - abc2[i]) < 1e-5,
|
||||
`Mismatch at index ${i}: ${abc1[i]} vs ${abc2[i]}`
|
||||
);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 200, seed: 42 }
|
||||
);
|
||||
});
|
||||
|
||||
it('property: inverse transform recovers original coordinates', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({ tx: finiteFloatArb, ty: finiteFloatArb, sx: positiveFloatArb, sy: positiveFloatArb }),
|
||||
fc.tuple(finiteFloatArb, finiteFloatArb),
|
||||
({ tx, ty, sx, sy }, [x, y]) => {
|
||||
const m = multiply4x4(
|
||||
translateMatrix(tx, ty),
|
||||
scaleMatrix(sx, sy)
|
||||
);
|
||||
const inv = invert4x4(m);
|
||||
assert.ok(inv, 'Matrix should be invertible');
|
||||
|
||||
const transformed = transformPoint(m, x, y);
|
||||
const recovered = transformPoint(inv!, transformed[0], transformed[1]);
|
||||
|
||||
assert.ok(Math.abs(recovered[0] - x) < 1e-4, `x mismatch: ${recovered[0]} vs ${x}`);
|
||||
assert.ok(Math.abs(recovered[1] - y) < 1e-4, `y mismatch: ${recovered[1]} vs ${y}`);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200, seed: 123 }
|
||||
);
|
||||
});
|
||||
|
||||
it('property: coordinate conversion is consistent (A->B->A = identity)', () => {
|
||||
// Build a minimal world with two frames: viewport (id=1) and child (id=2)
|
||||
const makeWorld = (originX: number, originY: number): GeometryWorld => {
|
||||
// String table indices: 0=scene_1, 1=snap_1, 2=en-US, 3=horizontal-tb
|
||||
const strings: StringTable = {
|
||||
values: ['scene_1', 'snap_1', 'en-US', 'horizontal-tb', 'div']
|
||||
};
|
||||
return {
|
||||
sceneId: 0, // index into strings
|
||||
snapshotId: 1,
|
||||
env: {
|
||||
viewportWidth: 1440,
|
||||
viewportHeight: 900,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 0,
|
||||
pointer: 0,
|
||||
hover: 0,
|
||||
reducedMotion: false,
|
||||
locale: 2,
|
||||
writingMode: 3,
|
||||
},
|
||||
source: {
|
||||
url: 0,
|
||||
browserName: 0,
|
||||
browserVersion: 0,
|
||||
engine: 0,
|
||||
extractedAt: Date.now(),
|
||||
},
|
||||
strings,
|
||||
subjects: {
|
||||
ids: new Uint32Array([1]),
|
||||
domNodeId: new Uint32Array([10]),
|
||||
subjectKind: new Uint16Array([1]),
|
||||
primaryBoxId: new Uint32Array([100]),
|
||||
firstFragmentId: new Uint32Array([0]),
|
||||
fragmentCount: new Uint16Array([0]),
|
||||
firstTextRunId: new Uint32Array([0]),
|
||||
textRunCount: new Uint16Array([0]),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array([10]),
|
||||
backendNodeId: new Uint32Array([1000]),
|
||||
parentNodeId: new Uint32Array([0]),
|
||||
firstChildIndex: new Uint32Array([0]),
|
||||
childCount: new Uint16Array([0]),
|
||||
shadowRootKind: new Uint8Array([0]),
|
||||
tagNameStringId: new Uint32Array([4]),
|
||||
classNameStringIds: [],
|
||||
roleStringId: new Uint32Array([0]),
|
||||
ariaNameStringId: new Uint32Array([0]),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array([100]),
|
||||
subjectId: new Uint32Array([1]),
|
||||
frameId: new Uint32Array([2]),
|
||||
borderLeft: new Float64Array([0]),
|
||||
borderTop: new Float64Array([0]),
|
||||
borderRight: new Float64Array([100]),
|
||||
borderBottom: new Float64Array([100]),
|
||||
paddingLeft: new Float64Array([0]),
|
||||
paddingTop: new Float64Array([0]),
|
||||
paddingRight: new Float64Array([100]),
|
||||
paddingBottom: new Float64Array([100]),
|
||||
contentLeft: new Float64Array([0]),
|
||||
contentTop: new Float64Array([0]),
|
||||
contentRight: new Float64Array([100]),
|
||||
contentBottom: new Float64Array([100]),
|
||||
},
|
||||
rects: {
|
||||
rectId: new Uint32Array([1]),
|
||||
left: new Float64Array([0]),
|
||||
top: new Float64Array([0]),
|
||||
right: new Float64Array([1440]),
|
||||
bottom: new Float64Array([900]),
|
||||
},
|
||||
frames: {
|
||||
frameId: new Uint32Array([1, 2]),
|
||||
frameKind: new Uint16Array([FrameKind.Viewport, FrameKind.ContainingBlock]),
|
||||
parentFrameId: new Uint32Array([0, 1]),
|
||||
ownerSubjectId: new Uint32Array([0, 1]),
|
||||
originX: new Float64Array([0, originX]),
|
||||
originY: new Float64Array([0, originY]),
|
||||
clipRectId: new Uint32Array([0, 0]),
|
||||
scrollContainerId: new Uint32Array([0, 0]),
|
||||
writingMode: new Uint16Array([3, 3]),
|
||||
axisMatrixStart: new Int32Array([-1, -1]),
|
||||
},
|
||||
matrices: { values: new Float64Array(0) },
|
||||
fragments: {
|
||||
fragmentId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
fragmentKind: new Uint16Array(0),
|
||||
boxLeft: new Float64Array(0),
|
||||
boxTop: new Float64Array(0),
|
||||
boxRight: new Float64Array(0),
|
||||
boxBottom: new Float64Array(0),
|
||||
lineIndex: new Int32Array(0),
|
||||
flowIndex: new Int32Array(0),
|
||||
parentFragmentId: new Uint32Array(0),
|
||||
},
|
||||
transforms: {
|
||||
transformId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
matrixStart: new Int32Array(0),
|
||||
matrixLength: new Uint16Array(0),
|
||||
originX: new Float64Array(0),
|
||||
originY: new Float64Array(0),
|
||||
},
|
||||
styles: {
|
||||
subjectId: new Uint32Array([1]),
|
||||
display: new Uint16Array([1]),
|
||||
position: new Uint16Array([1]),
|
||||
zIndexKind: new Uint8Array([0]),
|
||||
zIndexValue: new Int32Array([0]),
|
||||
overflowX: new Uint16Array([1]),
|
||||
overflowY: new Uint16Array([1]),
|
||||
opacity: new Float32Array([1]),
|
||||
visibility: new Uint16Array([1]),
|
||||
containFlags: new Uint32Array([0]),
|
||||
pointerEvents: new Uint16Array([1]),
|
||||
lineHeight: new Float64Array([1.5]),
|
||||
fontFamilyStringId: new Uint32Array([0]),
|
||||
fontSize: new Float64Array([16]),
|
||||
fontWeight: new Uint16Array([400]),
|
||||
},
|
||||
text: {
|
||||
runId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
contentStringId: new Uint32Array(0),
|
||||
lineBoxId: new Uint32Array(0),
|
||||
inkLeft: new Float64Array(0),
|
||||
inkTop: new Float64Array(0),
|
||||
inkRight: new Float64Array(0),
|
||||
inkBottom: new Float64Array(0),
|
||||
baselineY: new Float64Array(0),
|
||||
capHeight: new Float64Array(0),
|
||||
computedLineHeight: new Float64Array(0),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array([0, 0]),
|
||||
nearestPositionedAncestorOf: new Uint32Array([0, 0]),
|
||||
scrollContainerOf: new Uint32Array([0, 0]),
|
||||
stackingContextOf: new Uint32Array([0, 0]),
|
||||
formattingContextOf: new Uint32Array([0, 0]),
|
||||
clippingRootOf: new Uint32Array([0, 0]),
|
||||
paintOrderBucket: new Uint16Array([0, 0]),
|
||||
paintOrderIndex: new Uint32Array([0, 0]),
|
||||
},
|
||||
scroll: {
|
||||
containerId: new Uint32Array(0),
|
||||
scrollLeft: new Float64Array(0),
|
||||
scrollTop: new Float64Array(0),
|
||||
scrollWidth: new Float64Array(0),
|
||||
scrollHeight: new Float64Array(0),
|
||||
clientWidth: new Float64Array(0),
|
||||
clientHeight: new Float64Array(0),
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
clipKind: new Uint16Array(0),
|
||||
clipLeft: new Float64Array(0),
|
||||
clipTop: new Float64Array(0),
|
||||
clipRight: new Float64Array(0),
|
||||
clipBottom: new Float64Array(0),
|
||||
parentClipNodeId: new Uint32Array(0),
|
||||
},
|
||||
paint: {
|
||||
paintNodeId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
stackingContextId: new Uint32Array(0),
|
||||
bucket: new Uint16Array(0),
|
||||
localPaintIndex: new Uint32Array(0),
|
||||
},
|
||||
visibility: {
|
||||
subjectId: new Uint32Array([1]),
|
||||
isRendered: new Uint8Array([1]),
|
||||
isVisible: new Uint8Array([1]),
|
||||
visibleArea: new Float64Array([10000]),
|
||||
clippedArea: new Float64Array([0]),
|
||||
},
|
||||
provenance: {
|
||||
factId: new Uint32Array(0),
|
||||
extractionStepId: new Uint32Array(0),
|
||||
sourceKind: new Uint16Array(0),
|
||||
sourceRef: new Uint32Array(0),
|
||||
},
|
||||
confidence: {
|
||||
factId: new Uint32Array(0),
|
||||
confidence: new Float32Array(0),
|
||||
reasonCode: new Uint16Array(0),
|
||||
},
|
||||
selectorIndex: new Map(),
|
||||
ancestorIndex: new Map(),
|
||||
lineBoxIndex: new Map(),
|
||||
textRunIndex: new Map(),
|
||||
};
|
||||
};
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.tuple(finiteFloatArb, finiteFloatArb),
|
||||
fc.tuple(finiteFloatArb, finiteFloatArb),
|
||||
([ox, oy], [px, py]) => {
|
||||
const world = makeWorld(ox, oy);
|
||||
const index = buildWorldIndex(world);
|
||||
|
||||
// Convert point from frame 1 (viewport index) to frame 2 (child index) and back
|
||||
const p1 = convertPoint(world, 0, 1, px, py, index);
|
||||
if (!p1) return; // Skip singular transforms
|
||||
|
||||
const p0 = convertPoint(world, 1, 0, p1.x, p1.y, index);
|
||||
assert.ok(p0, 'Reverse conversion should succeed');
|
||||
|
||||
assert.ok(Math.abs(p0!.x - px) < 1e-4, `x round-trip mismatch: ${p0!.x} vs ${px}`);
|
||||
assert.ok(Math.abs(p0!.y - py) < 1e-4, `y round-trip mismatch: ${p0!.y} vs ${py}`);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200, seed: 77 }
|
||||
);
|
||||
});
|
||||
|
||||
it('property: box intersection is commutative', () => {
|
||||
fc.assert(
|
||||
fc.property(rectArb, rectArb, (a, b) => {
|
||||
const ab = rectIntersection(a, b);
|
||||
const ba = rectIntersection(b, a);
|
||||
|
||||
if (ab === null) {
|
||||
assert.strictEqual(ba, null, 'Intersection should be null both ways');
|
||||
} else {
|
||||
assert.ok(ba !== null, 'Intersection should not be null');
|
||||
assert.strictEqual(ab.left, ba.left);
|
||||
assert.strictEqual(ab.top, ba.top);
|
||||
assert.strictEqual(ab.right, ba.right);
|
||||
assert.strictEqual(ab.bottom, ba.bottom);
|
||||
}
|
||||
|
||||
assert.strictEqual(rectsIntersect(a, b), rectsIntersect(b, a));
|
||||
}),
|
||||
{ numRuns: 300, seed: 88 }
|
||||
);
|
||||
});
|
||||
|
||||
it('property: string table deduplication never duplicates', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(stringArb, { minLength: 1, maxLength: 50 }),
|
||||
(strings) => {
|
||||
const table: StringTable = { values: [] };
|
||||
const indices: number[] = [];
|
||||
|
||||
for (const s of strings) {
|
||||
indices.push(internString(table, s));
|
||||
}
|
||||
|
||||
// Check that identical strings map to identical indices
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
for (let j = i + 1; j < strings.length; j++) {
|
||||
if (strings[i] === strings[j]) {
|
||||
assert.strictEqual(
|
||||
indices[i],
|
||||
indices[j],
|
||||
`Duplicate string "${strings[i]}" should have same index`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that the number of unique values matches unique indices
|
||||
const uniqueIndices = new Set(indices);
|
||||
assert.ok(uniqueIndices.size <= table.values.length, 'Unique indices should not exceed table size');
|
||||
|
||||
// Verify all strings are retrievable
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
assert.strictEqual(table.values[indices[i]], strings[i]);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 200, seed: 55 }
|
||||
);
|
||||
});
|
||||
|
||||
it('property: buildTransformChain with identity matrices is identity', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.constant(new Float64Array(IDENTITY_4X4)), { minLength: 1, maxLength: 10 }),
|
||||
(matrices) => {
|
||||
const result = buildTransformChain(matrices);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
assert.ok(
|
||||
Math.abs(result[i] - IDENTITY_4X4[i]) < 1e-10,
|
||||
`Identity chain should yield identity at index ${i}`
|
||||
);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100, seed: 11 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// Query accelerators for common domain enumeration patterns (V1.1)
|
||||
// Bridges the logic engine's domain references to concrete element arrays.
|
||||
|
||||
import { GeometryWorld } from './world.js'
|
||||
import { getElementsBySelector, getDescendants } from './domain-index.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain reference shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ElementDomainRef {
|
||||
kind: 'elements'
|
||||
selector: string
|
||||
}
|
||||
|
||||
export interface DescendantDomainRef {
|
||||
kind: 'descendants'
|
||||
parentId: number
|
||||
selector?: string
|
||||
}
|
||||
|
||||
export interface LineBoxDomainRef {
|
||||
kind: 'lineBoxes'
|
||||
textNodeId: number
|
||||
}
|
||||
|
||||
export interface TextRunDomainRef {
|
||||
kind: 'textRuns'
|
||||
textNodeId: number
|
||||
}
|
||||
|
||||
export type DomainRef =
|
||||
| ElementDomainRef
|
||||
| DescendantDomainRef
|
||||
| LineBoxDomainRef
|
||||
| TextRunDomainRef
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query accelerator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a domain reference to a flat array of IDs.
|
||||
* The returned array is owned by the underlying index (no copy) unless
|
||||
* a descendant selector filter requires an intersection.
|
||||
*/
|
||||
export function queryDomain(world: GeometryWorld, domainRef: DomainRef): number[] {
|
||||
switch (domainRef.kind) {
|
||||
case 'elements':
|
||||
return getElementsBySelector(world, domainRef.selector)
|
||||
case 'descendants':
|
||||
return getDescendants(world, domainRef.parentId, domainRef.selector)
|
||||
case 'lineBoxes':
|
||||
return world.lineBoxIndex.get(domainRef.textNodeId) ?? []
|
||||
case 'textRuns':
|
||||
return world.textRunIndex.get(domainRef.textNodeId) ?? []
|
||||
default:
|
||||
// Exhaustiveness check — if we reach here, a new domain kind was added.
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// Transform matrix operations for Imhotep geometry world
|
||||
// CSS 4x4 column-major matrices stored flat in Float64Array
|
||||
|
||||
import { MATRIX_4X4_STRIDE } from './world.js'
|
||||
|
||||
// Identity 4x4 matrix (column-major)
|
||||
export const IDENTITY_4X4 = Object.freeze([
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
])
|
||||
|
||||
// Pre-allocate a scratch buffer to avoid GC in hot paths
|
||||
const _scratch = new Float64Array(16)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memoization caches for transform chains — injected so callers can clear them
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LruCacheEntry<V> {
|
||||
value: V
|
||||
key: string
|
||||
}
|
||||
|
||||
class LruTransformCache<V> {
|
||||
private cache = new Map<string, LruCacheEntry<V>>()
|
||||
constructor(private maxSize = 64) {}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const entry = this.cache.get(key)
|
||||
if (entry) {
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, entry)
|
||||
return entry.value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key)
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
const first = this.cache.keys().next().value
|
||||
if (first !== undefined) {
|
||||
this.cache.delete(first)
|
||||
}
|
||||
}
|
||||
this.cache.set(key, { key, value })
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache for buildTransformChain results — keyed by matrix array contents. */
|
||||
const _chainCache = new LruTransformCache<Float64Array>()
|
||||
|
||||
/** Cache for composeTransform2D results — keyed by parameter tuple. */
|
||||
const _composeCache = new LruTransformCache<Float64Array>()
|
||||
|
||||
/**
|
||||
* Serialize a matrix array into a cache key.
|
||||
* Uses a fast numeric hash to avoid long string concatenation.
|
||||
*/
|
||||
function hashMatrices(matrices: (Float64Array | number[])[]): string {
|
||||
let h = 0
|
||||
for (const m of matrices) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const v = m[i] ?? 0
|
||||
h = (h * 31 + (v * 1000) | 0) | 0
|
||||
}
|
||||
}
|
||||
return String(h)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear internal transform memoization caches.
|
||||
* Call this after large scene changes or in long-running processes.
|
||||
*/
|
||||
export function clearTransformCaches(): void {
|
||||
_chainCache.clear()
|
||||
_composeCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 4x4 matrix from a flat array at the given offset.
|
||||
*/
|
||||
export function readMatrix4x4(
|
||||
src: Float64Array,
|
||||
offset: number,
|
||||
out: Float64Array = new Float64Array(16)
|
||||
): Float64Array {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
out[i] = src[offset + i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a 4x4 matrix into a flat array at the given offset.
|
||||
*/
|
||||
export function writeMatrix4x4(
|
||||
dst: Float64Array,
|
||||
offset: number,
|
||||
mat: Float64Array | number[]
|
||||
): void {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
dst[offset + i] = mat[i]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply two 4x4 matrices: out = a * b (column-major).
|
||||
* Reuses an internal scratch buffer; not re-entrant across the same call frame.
|
||||
*/
|
||||
export function multiply4x4(
|
||||
a: Float64Array | number[],
|
||||
b: Float64Array | number[],
|
||||
out: Float64Array = new Float64Array(16)
|
||||
): Float64Array {
|
||||
const s = _scratch
|
||||
for (let i = 0; i < 16; i++) s[i] = 0
|
||||
|
||||
for (let col = 0; col < 4; col++) {
|
||||
for (let row = 0; row < 4; row++) {
|
||||
let sum = 0
|
||||
for (let k = 0; k < 4; k++) {
|
||||
sum += a[k * 4 + row] * b[col * 4 + k]
|
||||
}
|
||||
s[col * 4 + row] = sum
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
out[i] = s[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 2D translation matrix.
|
||||
*/
|
||||
export function translateMatrix(tx: number, ty: number, tz = 0): Float64Array {
|
||||
const m = new Float64Array(IDENTITY_4X4)
|
||||
m[12] = tx
|
||||
m[13] = ty
|
||||
m[14] = tz
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 2D scale matrix.
|
||||
*/
|
||||
export function scaleMatrix(sx: number, sy: number, sz = 1): Float64Array {
|
||||
const m = new Float64Array(IDENTITY_4X4)
|
||||
m[0] = sx
|
||||
m[5] = sy
|
||||
m[10] = sz
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 2D rotation matrix (angle in radians).
|
||||
*/
|
||||
export function rotateMatrix(angleRad: number): Float64Array {
|
||||
const c = Math.cos(angleRad)
|
||||
const s = Math.sin(angleRad)
|
||||
const m = new Float64Array(IDENTITY_4X4)
|
||||
m[0] = c
|
||||
m[1] = s
|
||||
m[4] = -s
|
||||
m[5] = c
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a point (x, y) by a 4x4 matrix, returning [x', y', z'].
|
||||
* Uses z = 0 and w = 1. Returns only x and y in the output array.
|
||||
*/
|
||||
export function transformPoint(
|
||||
mat: Float64Array | number[],
|
||||
x: number,
|
||||
y: number,
|
||||
out: Float64Array = new Float64Array(2)
|
||||
): Float64Array {
|
||||
const x1 = mat[0] * x + mat[4] * y + mat[12]
|
||||
const y1 = mat[1] * x + mat[5] * y + mat[13]
|
||||
out[0] = x1
|
||||
out[1] = y1
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete CSS-style 2D transform matrix from components.
|
||||
* Order: translate * rotate * scale * translate(-origin)
|
||||
*/
|
||||
export function composeTransform2D(
|
||||
originX: number,
|
||||
originY: number,
|
||||
translateX: number,
|
||||
translateY: number,
|
||||
rotateRad: number,
|
||||
scaleX: number,
|
||||
scaleY: number
|
||||
): Float64Array {
|
||||
const cacheKey = `${originX},${originY},${translateX},${translateY},${rotateRad},${scaleX},${scaleY}`
|
||||
const cached = _composeCache.get(cacheKey)
|
||||
if (cached) {
|
||||
return cached.slice() as Float64Array
|
||||
}
|
||||
|
||||
// M = T(translate) * R(rotate) * S(scale) * T(-origin)
|
||||
const t1 = translateMatrix(translateX, translateY)
|
||||
const r = rotateMatrix(rotateRad)
|
||||
const s = scaleMatrix(scaleX, scaleY)
|
||||
const t0 = translateMatrix(-originX, -originY)
|
||||
|
||||
const tmp1 = multiply4x4(s, t0)
|
||||
const tmp2 = multiply4x4(r, tmp1)
|
||||
const result = multiply4x4(t1, tmp2)
|
||||
_composeCache.set(cacheKey, result.slice() as Float64Array)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert a 4x4 matrix. Returns null if singular.
|
||||
* Uses Gaussian elimination on an augmented matrix.
|
||||
*/
|
||||
export function invert4x4(
|
||||
mat: Float64Array | number[],
|
||||
out: Float64Array = new Float64Array(16)
|
||||
): Float64Array | null {
|
||||
// Build augmented matrix [mat | I]
|
||||
const aug = new Float64Array(32)
|
||||
for (let i = 0; i < 16; i++) aug[i] = mat[i]
|
||||
for (let i = 0; i < 4; i++) {
|
||||
aug[16 + i * 4 + i] = 1
|
||||
}
|
||||
|
||||
// Forward elimination
|
||||
for (let col = 0; col < 4; col++) {
|
||||
// Find pivot
|
||||
let pivotRow = col
|
||||
let pivotVal = Math.abs(aug[col * 4 + col])
|
||||
for (let row = col + 1; row < 4; row++) {
|
||||
const v = Math.abs(aug[col * 4 + row])
|
||||
if (v > pivotVal) {
|
||||
pivotVal = v
|
||||
pivotRow = row
|
||||
}
|
||||
}
|
||||
|
||||
if (pivotVal < 1e-12) {
|
||||
return null // Singular
|
||||
}
|
||||
|
||||
// Swap rows if needed
|
||||
if (pivotRow !== col) {
|
||||
for (let j = 0; j < 8; j++) {
|
||||
const idx1 = j * 4 + col
|
||||
const idx2 = j * 4 + pivotRow
|
||||
const tmp = aug[idx1]
|
||||
aug[idx1] = aug[idx2]
|
||||
aug[idx2] = tmp
|
||||
}
|
||||
}
|
||||
|
||||
// Scale pivot row
|
||||
const pivot = aug[col * 4 + col]
|
||||
for (let j = 0; j < 8; j++) {
|
||||
aug[j * 4 + col] /= pivot
|
||||
}
|
||||
|
||||
// Eliminate other rows
|
||||
for (let row = 0; row < 4; row++) {
|
||||
if (row === col) continue
|
||||
const factor = aug[col * 4 + row]
|
||||
for (let j = 0; j < 8; j++) {
|
||||
aug[j * 4 + row] -= factor * aug[j * 4 + col]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract inverse from augmented right half
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
out[j * 4 + i] = aug[(j + 4) * 4 + i]
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a matrix to a flat matrix buffer, returning the start index.
|
||||
*/
|
||||
export function appendMatrix(
|
||||
buffer: Float64Array,
|
||||
used: number,
|
||||
mat: Float64Array | number[]
|
||||
): { buffer: Float64Array; start: number; used: number } {
|
||||
const required = used + MATRIX_4X4_STRIDE
|
||||
if (required > buffer.length) {
|
||||
const newBuf = new Float64Array(Math.max(buffer.length * 2, required))
|
||||
newBuf.set(buffer)
|
||||
buffer = newBuf
|
||||
}
|
||||
writeMatrix4x4(buffer, used, mat)
|
||||
return { buffer, start: used, used: required }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a transform chain by multiplying matrices in order.
|
||||
* The first matrix in the chain is the outermost transform (closest to root).
|
||||
*/
|
||||
export function buildTransformChain(
|
||||
matrices: (Float64Array | number[])[],
|
||||
out: Float64Array = new Float64Array(16)
|
||||
): Float64Array {
|
||||
if (matrices.length === 0) {
|
||||
out.set(IDENTITY_4X4)
|
||||
return out
|
||||
}
|
||||
|
||||
// Fast path: cache hit for identical transform chains (common in repeated elements).
|
||||
const cacheKey = hashMatrices(matrices)
|
||||
const cached = _chainCache.get(cacheKey)
|
||||
if (cached) {
|
||||
out.set(cached)
|
||||
return out
|
||||
}
|
||||
|
||||
out.set(matrices[0])
|
||||
const tmp = new Float64Array(16)
|
||||
for (let i = 1; i < matrices.length; i++) {
|
||||
multiply4x4(out, matrices[i], tmp)
|
||||
out.set(tmp)
|
||||
}
|
||||
|
||||
_chainCache.set(cacheKey, out.slice() as Float64Array)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
GeometryWorld,
|
||||
StringTable,
|
||||
SubjectKind,
|
||||
FrameKind,
|
||||
DisplayValue,
|
||||
PositionValue,
|
||||
buildWorldIndex,
|
||||
WorldIndex,
|
||||
internString,
|
||||
} from './world.js'
|
||||
import {
|
||||
multiply4x4,
|
||||
translateMatrix,
|
||||
scaleMatrix,
|
||||
rotateMatrix,
|
||||
transformPoint,
|
||||
invert4x4,
|
||||
buildTransformChain,
|
||||
IDENTITY_4X4,
|
||||
} from './transforms.js'
|
||||
import {
|
||||
resolveViewportFrame,
|
||||
walkFrameAncestors,
|
||||
buildFrameTransform,
|
||||
buildFrameToFrameTransform,
|
||||
convertPoint,
|
||||
convertRect,
|
||||
} from './frames.js'
|
||||
import {
|
||||
getBorderBox,
|
||||
getPaddingBox,
|
||||
getContentBox,
|
||||
rectSize,
|
||||
rectArea,
|
||||
rectsIntersect,
|
||||
rectIntersection,
|
||||
getPrimaryBoxInViewport,
|
||||
} from './boxes.js'
|
||||
import {
|
||||
materializeWorld,
|
||||
materializeWorldWithIndex,
|
||||
validateWorld,
|
||||
} from './materialize.js'
|
||||
import {
|
||||
RawExtractionResult,
|
||||
RawSubject,
|
||||
RawFrame,
|
||||
RawBox,
|
||||
RawRect,
|
||||
} from './normalize.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build a minimal valid raw extraction result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMinimalRaw(): RawExtractionResult {
|
||||
return {
|
||||
sceneId: 'scene_1',
|
||||
snapshotId: 'snap_1',
|
||||
env: {
|
||||
viewportWidth: 1440,
|
||||
viewportHeight: 900,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
pointer: 'fine',
|
||||
hover: 'none',
|
||||
reducedMotion: false,
|
||||
locale: 'en-US',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
source: {
|
||||
url: 'https://example.test',
|
||||
browserName: 'Chromium',
|
||||
browserVersion: '123',
|
||||
engine: 'chromium-cdp',
|
||||
extractedAt: Date.now(),
|
||||
},
|
||||
subjects: [
|
||||
{ id: 1, domNodeId: 10, kind: 'element', primaryBoxId: 100, firstFragmentId: 0, fragmentCount: 0, firstTextRunId: 0, textRunCount: 0 },
|
||||
{ id: 2, domNodeId: 20, kind: 'element', primaryBoxId: 200, firstFragmentId: 0, fragmentCount: 0, firstTextRunId: 0, textRunCount: 0 },
|
||||
],
|
||||
dom: [
|
||||
{ nodeId: 10, backendNodeId: 1000, parentNodeId: 0, firstChildIndex: 0, childCount: 0, shadowRootKind: '', tagName: 'div', classNames: [], role: '', ariaName: '' },
|
||||
{ nodeId: 20, backendNodeId: 2000, parentNodeId: 10, firstChildIndex: 0, childCount: 0, shadowRootKind: '', tagName: 'span', classNames: [], role: '', ariaName: '' },
|
||||
],
|
||||
frames: [
|
||||
{ id: 1, kind: 'viewport', ownerSubjectId: 0, parentFrameId: 0, originX: 0, originY: 0, clipRectId: 0, scrollContainerId: 0, writingMode: 'horizontal-tb' },
|
||||
{ id: 2, kind: 'containingBlock', ownerSubjectId: 1, parentFrameId: 1, originX: 10, originY: 20, clipRectId: 0, scrollContainerId: 0, writingMode: 'horizontal-tb' },
|
||||
],
|
||||
rects: [
|
||||
{ id: 1, left: 0, top: 0, right: 1440, bottom: 900 },
|
||||
],
|
||||
boxes: [
|
||||
{ id: 100, subjectId: 1, frameId: 2, borderLeft: 10, borderTop: 20, borderRight: 110, borderBottom: 120, paddingLeft: 12, paddingTop: 22, paddingRight: 108, paddingBottom: 118, contentLeft: 14, contentTop: 24, contentRight: 106, contentBottom: 116 },
|
||||
{ id: 200, subjectId: 2, frameId: 2, borderLeft: 30, borderTop: 40, borderRight: 130, borderBottom: 140, paddingLeft: 32, paddingTop: 42, paddingRight: 128, paddingBottom: 138, contentLeft: 34, contentTop: 44, contentRight: 126, contentBottom: 136 },
|
||||
],
|
||||
fragments: [],
|
||||
transforms: [],
|
||||
styles: [
|
||||
{ subjectId: 1, display: 'block', position: 'static', zIndex: 'auto', overflowX: 'visible', overflowY: 'visible', opacity: 1, visibility: 'visible', contain: '', pointerEvents: 'auto', lineHeight: 1.5, fontFamily: 'sans-serif', fontSize: 16, fontWeight: 400 },
|
||||
],
|
||||
text: [],
|
||||
topology: {
|
||||
containingBlockOf: [0, 1],
|
||||
nearestPositionedAncestorOf: [0, 0],
|
||||
scrollContainerOf: [0, 0],
|
||||
stackingContextOf: [0, 0],
|
||||
formattingContextOf: [0, 0],
|
||||
clippingRootOf: [0, 0],
|
||||
paintOrderBucket: [0, 0],
|
||||
paintOrderIndex: [0, 0],
|
||||
},
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
paint: [],
|
||||
visibility: [
|
||||
{ subjectId: 1, isRendered: true, isVisible: true, visibleArea: 10000, clippedArea: 0 },
|
||||
{ subjectId: 2, isRendered: true, isVisible: true, visibleArea: 10000, clippedArea: 0 },
|
||||
],
|
||||
provenance: [],
|
||||
confidence: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// World construction tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('world construction', () => {
|
||||
it('materializes a minimal world', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const world = materializeWorld(raw)
|
||||
|
||||
assert.strictEqual(world.subjects.ids.length, 2)
|
||||
assert.strictEqual(world.boxes.boxId.length, 2)
|
||||
assert.strictEqual(world.frames.frameId.length, 2)
|
||||
assert.strictEqual(world.strings.values.length > 0, true)
|
||||
})
|
||||
|
||||
it('builds a world index', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const { world, index } = materializeWorldWithIndex(raw)
|
||||
|
||||
assert.strictEqual(index.subjectById.get(1), 0)
|
||||
assert.strictEqual(index.subjectById.get(2), 1)
|
||||
assert.strictEqual(index.boxById.get(100), 0)
|
||||
assert.strictEqual(index.frameById.get(1), 0)
|
||||
assert.strictEqual(index.frameById.get(2), 1)
|
||||
})
|
||||
|
||||
it('interns strings into a deduplicated table', () => {
|
||||
const table: StringTable = { values: [] }
|
||||
const idx1 = internString(table, 'hello')
|
||||
const idx2 = internString(table, 'hello')
|
||||
const idx3 = internString(table, 'world')
|
||||
|
||||
assert.strictEqual(idx1, idx2)
|
||||
assert.notStrictEqual(idx1, idx3)
|
||||
assert.strictEqual(table.values.length, 2)
|
||||
})
|
||||
|
||||
it('validates a correct world with no errors', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const world = materializeWorld(raw)
|
||||
const errors = validateWorld(world)
|
||||
assert.strictEqual(errors.length, 0)
|
||||
})
|
||||
|
||||
it('detects invalid box subject references', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
raw.boxes.push({
|
||||
id: 300,
|
||||
subjectId: 999, // nonexistent
|
||||
frameId: 1,
|
||||
borderLeft: 0,
|
||||
borderTop: 0,
|
||||
borderRight: 10,
|
||||
borderBottom: 10,
|
||||
paddingLeft: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: 10,
|
||||
paddingBottom: 10,
|
||||
contentLeft: 0,
|
||||
contentTop: 0,
|
||||
contentRight: 10,
|
||||
contentBottom: 10,
|
||||
})
|
||||
const world = materializeWorld(raw)
|
||||
const errors = validateWorld(world)
|
||||
const invalidSubject = errors.find((e) => e.code === 'INVALID_SUBJECT_REF')
|
||||
assert.ok(invalidSubject)
|
||||
})
|
||||
|
||||
it('detects invalid frame parent references', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
raw.frames.push({
|
||||
id: 3,
|
||||
kind: 'containingBlock',
|
||||
ownerSubjectId: 2,
|
||||
parentFrameId: 999, // nonexistent
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
clipRectId: 0,
|
||||
scrollContainerId: 0,
|
||||
writingMode: 'horizontal-tb',
|
||||
})
|
||||
const world = materializeWorld(raw)
|
||||
const errors = validateWorld(world)
|
||||
const invalidParent = errors.find((e) => e.code === 'INVALID_PARENT_FRAME')
|
||||
assert.ok(invalidParent)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame resolution tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('frame resolution', () => {
|
||||
it('resolves the viewport frame', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const world = materializeWorld(raw)
|
||||
const viewportIdx = resolveViewportFrame(world.frames)
|
||||
assert.strictEqual(viewportIdx, 0)
|
||||
assert.strictEqual(world.frames.frameKind[viewportIdx], FrameKind.Viewport)
|
||||
})
|
||||
|
||||
it('walks frame ancestors', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const world = materializeWorld(raw)
|
||||
const ancestors = walkFrameAncestors(world.frames, 1) // frame 2 (child of viewport)
|
||||
assert.deepStrictEqual(ancestors, [1, 0])
|
||||
})
|
||||
|
||||
it('handles orphaned frames gracefully', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
raw.frames.push({
|
||||
id: 3,
|
||||
kind: 'synthetic',
|
||||
ownerSubjectId: 0,
|
||||
parentFrameId: 0,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
clipRectId: 0,
|
||||
scrollContainerId: 0,
|
||||
writingMode: 'horizontal-tb',
|
||||
})
|
||||
const world = materializeWorld(raw)
|
||||
const ancestors = walkFrameAncestors(world.frames, 2)
|
||||
assert.deepStrictEqual(ancestors, [2])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transform matrix tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('transform matrices', () => {
|
||||
it('identity matrix leaves points unchanged', () => {
|
||||
const out = transformPoint(new Float64Array(IDENTITY_4X4), 5, 7)
|
||||
assert.strictEqual(out[0], 5)
|
||||
assert.strictEqual(out[1], 7)
|
||||
})
|
||||
|
||||
it('translation matrix moves points', () => {
|
||||
const m = translateMatrix(10, 20)
|
||||
const out = transformPoint(m, 5, 7)
|
||||
assert.strictEqual(out[0], 15)
|
||||
assert.strictEqual(out[1], 27)
|
||||
})
|
||||
|
||||
it('scale matrix scales points', () => {
|
||||
const m = scaleMatrix(2, 3)
|
||||
const out = transformPoint(m, 5, 7)
|
||||
assert.strictEqual(out[0], 10)
|
||||
assert.strictEqual(out[1], 21)
|
||||
})
|
||||
|
||||
it('rotation matrix rotates points', () => {
|
||||
const m = rotateMatrix(Math.PI / 2) // 90 degrees
|
||||
const out = transformPoint(m, 1, 0)
|
||||
// Should be approximately (0, 1)
|
||||
assert.ok(Math.abs(out[0]) < 1e-10)
|
||||
assert.ok(Math.abs(out[1] - 1) < 1e-10)
|
||||
})
|
||||
|
||||
it('matrix multiplication is associative', () => {
|
||||
const a = translateMatrix(1, 0)
|
||||
const b = scaleMatrix(2, 2)
|
||||
const c = rotateMatrix(Math.PI / 4)
|
||||
|
||||
const ab = multiply4x4(a, b)
|
||||
const abc1 = multiply4x4(ab, c)
|
||||
|
||||
const bc = multiply4x4(b, c)
|
||||
const abc2 = multiply4x4(a, bc)
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
assert.ok(
|
||||
Math.abs(abc1[i] - abc2[i]) < 1e-10,
|
||||
`Mismatch at index ${i}: ${abc1[i]} vs ${abc2[i]}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('inverse of identity is identity', () => {
|
||||
const inv = invert4x4(new Float64Array(IDENTITY_4X4))
|
||||
assert.ok(inv)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
assert.strictEqual(inv[i], IDENTITY_4X4[i])
|
||||
}
|
||||
})
|
||||
|
||||
it('inverse times original gives identity', () => {
|
||||
const m = translateMatrix(10, 20)
|
||||
const inv = invert4x4(m)
|
||||
assert.ok(inv)
|
||||
const prod = multiply4x4(m, inv)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const expected = IDENTITY_4X4[i]
|
||||
assert.ok(
|
||||
Math.abs(prod[i] - expected) < 1e-10,
|
||||
`Mismatch at ${i}: ${prod[i]} vs ${expected}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('buildTransformChain composes in order', () => {
|
||||
const t1 = translateMatrix(1, 0)
|
||||
const t2 = translateMatrix(0, 1)
|
||||
const chain = buildTransformChain([t1, t2])
|
||||
const out = transformPoint(chain, 0, 0)
|
||||
assert.strictEqual(out[0], 1)
|
||||
assert.strictEqual(out[1], 1)
|
||||
})
|
||||
|
||||
it('returns identity for empty chain', () => {
|
||||
const chain = buildTransformChain([])
|
||||
const out = transformPoint(chain, 5, 7)
|
||||
assert.strictEqual(out[0], 5)
|
||||
assert.strictEqual(out[1], 7)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate transform tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('coordinate transforms', () => {
|
||||
it('converts a point between identical frames', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const { world, index } = materializeWorldWithIndex(raw)
|
||||
const viewportIdx = resolveViewportFrame(world.frames)
|
||||
|
||||
// Frame 2 has origin (10, 20) relative to viewport, no transform
|
||||
const result = convertPoint(world, viewportIdx, 1, 5, 5, index)
|
||||
assert.ok(result)
|
||||
assert.strictEqual(result!.x, -5) // 5 - 10
|
||||
assert.strictEqual(result!.y, -15) // 5 - 20
|
||||
})
|
||||
|
||||
it('converts a rect between frames', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const { world, index } = materializeWorldWithIndex(raw)
|
||||
const viewportIdx = resolveViewportFrame(world.frames)
|
||||
|
||||
// Rect (0,0,50,50) in frame 1 (origin 10,20) maps to (10,20,60,70) in viewport
|
||||
const result = convertRect(world, 1, viewportIdx, 0, 0, 50, 50, index)
|
||||
assert.ok(result)
|
||||
assert.strictEqual(result!.left, 10)
|
||||
assert.strictEqual(result!.top, 20)
|
||||
assert.strictEqual(result!.right, 60)
|
||||
assert.strictEqual(result!.bottom, 70)
|
||||
})
|
||||
|
||||
it('applies transform matrices in frame chains', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
// Add a scale transform to frame 2
|
||||
const scaleMat = Array.from(scaleMatrix(2, 2))
|
||||
raw.frames[1].matrix = scaleMat
|
||||
const { world, index } = materializeWorldWithIndex(raw)
|
||||
const viewportIdx = resolveViewportFrame(world.frames)
|
||||
|
||||
// Point (10, 20) in frame 2: scaled by 2, then offset by origin (10, 20)
|
||||
// In viewport: (10*2 + 10, 20*2 + 20) = (30, 60)
|
||||
// Inverse: viewport to frame 2
|
||||
const result = convertPoint(world, viewportIdx, 1, 30, 60, index)
|
||||
assert.ok(result)
|
||||
assert.ok(Math.abs(result!.x - 10) < 1e-10)
|
||||
assert.ok(Math.abs(result!.y - 20) < 1e-10)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Box geometry tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('box geometry', () => {
|
||||
it('reads border box correctly', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const world = materializeWorld(raw)
|
||||
const box = getBorderBox(world.boxes, 0)
|
||||
assert.strictEqual(box.left, 10)
|
||||
assert.strictEqual(box.top, 20)
|
||||
assert.strictEqual(box.right, 110)
|
||||
assert.strictEqual(box.bottom, 120)
|
||||
})
|
||||
|
||||
it('reads padding box correctly', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const world = materializeWorld(raw)
|
||||
const box = getPaddingBox(world.boxes, 0)
|
||||
assert.strictEqual(box.left, 12)
|
||||
assert.strictEqual(box.top, 22)
|
||||
assert.strictEqual(box.right, 108)
|
||||
assert.strictEqual(box.bottom, 118)
|
||||
})
|
||||
|
||||
it('reads content box correctly', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const world = materializeWorld(raw)
|
||||
const box = getContentBox(world.boxes, 0)
|
||||
assert.strictEqual(box.left, 14)
|
||||
assert.strictEqual(box.top, 24)
|
||||
assert.strictEqual(box.right, 106)
|
||||
assert.strictEqual(box.bottom, 116)
|
||||
})
|
||||
|
||||
it('computes rect size', () => {
|
||||
const size = rectSize({ left: 10, top: 20, right: 110, bottom: 120 })
|
||||
assert.strictEqual(size.width, 100)
|
||||
assert.strictEqual(size.height, 100)
|
||||
})
|
||||
|
||||
it('computes rect area', () => {
|
||||
const area = rectArea({ left: 0, top: 0, right: 10, bottom: 20 })
|
||||
assert.strictEqual(area, 200)
|
||||
})
|
||||
|
||||
it('detects intersecting rects', () => {
|
||||
const a = { left: 0, top: 0, right: 10, bottom: 10 }
|
||||
const b = { left: 5, top: 5, right: 15, bottom: 15 }
|
||||
assert.strictEqual(rectsIntersect(a, b), true)
|
||||
})
|
||||
|
||||
it('detects non-intersecting rects', () => {
|
||||
const a = { left: 0, top: 0, right: 10, bottom: 10 }
|
||||
const b = { left: 10, top: 10, right: 20, bottom: 20 }
|
||||
assert.strictEqual(rectsIntersect(a, b), false)
|
||||
})
|
||||
|
||||
it('computes rect intersection', () => {
|
||||
const a = { left: 0, top: 0, right: 10, bottom: 10 }
|
||||
const b = { left: 5, top: 5, right: 15, bottom: 15 }
|
||||
const inter = rectIntersection(a, b)
|
||||
assert.ok(inter)
|
||||
assert.strictEqual(inter!.left, 5)
|
||||
assert.strictEqual(inter!.top, 5)
|
||||
assert.strictEqual(inter!.right, 10)
|
||||
assert.strictEqual(inter!.bottom, 10)
|
||||
})
|
||||
|
||||
it('returns null for non-intersecting rects', () => {
|
||||
const a = { left: 0, top: 0, right: 10, bottom: 10 }
|
||||
const b = { left: 20, top: 20, right: 30, bottom: 30 }
|
||||
const inter = rectIntersection(a, b)
|
||||
assert.strictEqual(inter, null)
|
||||
})
|
||||
|
||||
it('gets primary box in viewport', () => {
|
||||
const raw = makeMinimalRaw()
|
||||
const { world, index } = materializeWorldWithIndex(raw)
|
||||
const box = getPrimaryBoxInViewport(world, 1, index)
|
||||
assert.ok(box)
|
||||
// Box 100 is in frame 2 with origin (10, 20)
|
||||
// border box: left=10, top=20, right=110, bottom=120
|
||||
// In viewport: offset by (10, 20) -> left=20, top=40, right=120, bottom=140
|
||||
assert.strictEqual(box!.left, 20)
|
||||
assert.strictEqual(box!.top, 40)
|
||||
assert.strictEqual(box!.right, 120)
|
||||
assert.strictEqual(box!.bottom, 140)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String table tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('string table', () => {
|
||||
it('deduplicates identical strings', () => {
|
||||
const table: StringTable = { values: [] }
|
||||
const a = internString(table, 'div')
|
||||
const b = internString(table, 'div')
|
||||
const c = internString(table, 'span')
|
||||
assert.strictEqual(a, b)
|
||||
assert.notStrictEqual(a, c)
|
||||
assert.strictEqual(table.values[a], 'div')
|
||||
assert.strictEqual(table.values[c], 'span')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,515 @@
|
||||
// Geometry world data structures for Imhotep
|
||||
// Flat arrays, integer IDs, string tables — data-oriented for hot-path performance
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment and source metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Env {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
deviceScaleFactor: number
|
||||
colorScheme: number // string table index
|
||||
pointer: number // string table index
|
||||
hover: number // string table index
|
||||
reducedMotion: boolean
|
||||
locale: number // string table index
|
||||
writingMode: number // string table index
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
url: number // string table index
|
||||
browserName: number // string table index
|
||||
browserVersion: number // string table index
|
||||
engine: number // string table index
|
||||
extractedAt: number // ms since epoch
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String table for deduplication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StringTable {
|
||||
values: string[]
|
||||
/** O(1) string-to-index lookup cache — populated lazily by internString. */
|
||||
indexByValue?: Map<string, number>
|
||||
}
|
||||
|
||||
export function internString(table: StringTable, value: string): number {
|
||||
// Fast path: use the Map cache if it exists.
|
||||
if (table.indexByValue) {
|
||||
const cached = table.indexByValue.get(value)
|
||||
if (cached !== undefined) return cached
|
||||
const idx = table.values.length
|
||||
table.values.push(value)
|
||||
table.indexByValue.set(value, idx)
|
||||
return idx
|
||||
}
|
||||
|
||||
// Slow path: linear scan (first call builds the cache if table is large).
|
||||
const idx = table.values.indexOf(value)
|
||||
if (idx !== -1) return idx
|
||||
|
||||
// Initialize the cache once the table grows beyond a threshold or on first intern miss.
|
||||
if (table.values.length >= 8) {
|
||||
table.indexByValue = new Map<string, number>()
|
||||
for (let i = 0; i < table.values.length; i++) {
|
||||
table.indexByValue.set(table.values[i], i)
|
||||
}
|
||||
const newIdx = table.values.length
|
||||
table.values.push(value)
|
||||
table.indexByValue.set(value, newIdx)
|
||||
return newIdx
|
||||
}
|
||||
|
||||
table.values.push(value)
|
||||
return table.values.length - 1
|
||||
}
|
||||
|
||||
export function getString(table: StringTable, index: number): string {
|
||||
return table.values[index] ?? ''
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subjects — resolvable test subjects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum SubjectKind {
|
||||
Element = 1,
|
||||
PseudoElement = 2,
|
||||
TextRange = 3,
|
||||
LineBox = 4,
|
||||
FragmentBox = 5,
|
||||
Landmark = 6,
|
||||
GridArea = 7,
|
||||
ScrollContainer = 8,
|
||||
SyntheticFrame = 9,
|
||||
}
|
||||
|
||||
export interface Subjects {
|
||||
ids: Uint32Array
|
||||
domNodeId: Uint32Array
|
||||
subjectKind: Uint16Array
|
||||
primaryBoxId: Uint32Array
|
||||
firstFragmentId: Uint32Array
|
||||
fragmentCount: Uint16Array
|
||||
firstTextRunId: Uint32Array
|
||||
textRunCount: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM ownership structure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Dom {
|
||||
nodeId: Uint32Array
|
||||
backendNodeId: Uint32Array
|
||||
parentNodeId: Uint32Array
|
||||
firstChildIndex: Uint32Array
|
||||
childCount: Uint16Array
|
||||
shadowRootKind: Uint8Array
|
||||
tagNameStringId: Uint32Array
|
||||
classNameStringIds: Uint32Array[]
|
||||
roleStringId: Uint32Array
|
||||
ariaNameStringId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reference frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum FrameKind {
|
||||
Viewport = 1,
|
||||
ContainingBlock = 2,
|
||||
NearestPositionedAncestor = 3,
|
||||
ScrollContainer = 4,
|
||||
Synthetic = 5,
|
||||
}
|
||||
|
||||
export interface Frames {
|
||||
frameId: Uint32Array
|
||||
frameKind: Uint16Array
|
||||
ownerSubjectId: Uint32Array
|
||||
parentFrameId: Uint32Array
|
||||
originX: Float64Array
|
||||
originY: Float64Array
|
||||
axisMatrixStart: Int32Array
|
||||
clipRectId: Uint32Array
|
||||
scrollContainerId: Uint32Array
|
||||
writingMode: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat transform matrix storage (CSS 4x4 column-major)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Matrices {
|
||||
values: Float64Array
|
||||
}
|
||||
|
||||
export const MATRIX_4X4_STRIDE = 16
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared rect storage for clip and frame bounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Rects {
|
||||
rectId: Uint32Array
|
||||
left: Float64Array
|
||||
top: Float64Array
|
||||
right: Float64Array
|
||||
bottom: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primary box geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Boxes {
|
||||
boxId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
frameId: Uint32Array
|
||||
borderLeft: Float64Array
|
||||
borderTop: Float64Array
|
||||
borderRight: Float64Array
|
||||
borderBottom: Float64Array
|
||||
paddingLeft: Float64Array
|
||||
paddingTop: Float64Array
|
||||
paddingRight: Float64Array
|
||||
paddingBottom: Float64Array
|
||||
contentLeft: Float64Array
|
||||
contentTop: Float64Array
|
||||
contentRight: Float64Array
|
||||
contentBottom: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fragment boxes for multi-line / multi-column content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum FragmentKind {
|
||||
Line = 1,
|
||||
Column = 2,
|
||||
Region = 3,
|
||||
Page = 4,
|
||||
}
|
||||
|
||||
export interface Fragments {
|
||||
fragmentId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
fragmentKind: Uint16Array
|
||||
boxLeft: Float64Array
|
||||
boxTop: Float64Array
|
||||
boxRight: Float64Array
|
||||
boxBottom: Float64Array
|
||||
lineIndex: Int32Array
|
||||
flowIndex: Int32Array
|
||||
parentFragmentId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transform chains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Transforms {
|
||||
transformId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
matrixStart: Int32Array
|
||||
matrixLength: Uint16Array
|
||||
originX: Float64Array
|
||||
originY: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Required style facts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const enum DisplayValue {
|
||||
Block = 1,
|
||||
Inline = 2,
|
||||
InlineBlock = 3,
|
||||
Flex = 4,
|
||||
Grid = 5,
|
||||
None = 6,
|
||||
Contents = 7,
|
||||
}
|
||||
|
||||
export const enum PositionValue {
|
||||
Static = 1,
|
||||
Relative = 2,
|
||||
Absolute = 3,
|
||||
Fixed = 4,
|
||||
Sticky = 5,
|
||||
}
|
||||
|
||||
export const enum OverflowValue {
|
||||
Visible = 1,
|
||||
Hidden = 2,
|
||||
Scroll = 3,
|
||||
Auto = 4,
|
||||
Clip = 5,
|
||||
}
|
||||
|
||||
export const enum VisibilityValue {
|
||||
Visible = 1,
|
||||
Hidden = 2,
|
||||
Collapse = 3,
|
||||
}
|
||||
|
||||
export interface Styles {
|
||||
subjectId: Uint32Array
|
||||
display: Uint16Array
|
||||
position: Uint16Array
|
||||
zIndexKind: Uint8Array
|
||||
zIndexValue: Int32Array
|
||||
overflowX: Uint16Array
|
||||
overflowY: Uint16Array
|
||||
opacity: Float32Array
|
||||
visibility: Uint16Array
|
||||
containFlags: Uint32Array
|
||||
pointerEvents: Uint16Array
|
||||
lineHeight: Float64Array
|
||||
fontFamilyStringId: Uint32Array
|
||||
fontSize: Float64Array
|
||||
fontWeight: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text geometry and metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TextRuns {
|
||||
runId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
contentStringId: Uint32Array
|
||||
lineBoxId: Uint32Array
|
||||
inkLeft: Float64Array
|
||||
inkTop: Float64Array
|
||||
inkRight: Float64Array
|
||||
inkBottom: Float64Array
|
||||
baselineY: Float64Array
|
||||
capHeight: Float64Array
|
||||
computedLineHeight: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topology graph relations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Topology {
|
||||
containingBlockOf: Uint32Array
|
||||
nearestPositionedAncestorOf: Uint32Array
|
||||
scrollContainerOf: Uint32Array
|
||||
stackingContextOf: Uint32Array
|
||||
formattingContextOf: Uint32Array
|
||||
clippingRootOf: Uint32Array
|
||||
paintOrderBucket: Uint16Array
|
||||
paintOrderIndex: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll-specific facts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Scroll {
|
||||
containerId: Uint32Array
|
||||
scrollLeft: Float64Array
|
||||
scrollTop: Float64Array
|
||||
scrollWidth: Float64Array
|
||||
scrollHeight: Float64Array
|
||||
clientWidth: Float64Array
|
||||
clientHeight: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clipping chain facts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const enum ClipKind {
|
||||
Overflow = 1,
|
||||
ClipPath = 2,
|
||||
Mask = 3,
|
||||
SvgClip = 4,
|
||||
}
|
||||
|
||||
export interface Clipping {
|
||||
clipNodeId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
clipKind: Uint16Array
|
||||
clipLeft: Float64Array
|
||||
clipTop: Float64Array
|
||||
clipRight: Float64Array
|
||||
clipBottom: Float64Array
|
||||
parentClipNodeId: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paint relations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Paint {
|
||||
paintNodeId: Uint32Array
|
||||
subjectId: Uint32Array
|
||||
stackingContextId: Uint32Array
|
||||
bucket: Uint16Array
|
||||
localPaintIndex: Uint32Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visibility facts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Visibility {
|
||||
subjectId: Uint32Array
|
||||
isRendered: Uint8Array
|
||||
isVisible: Uint8Array
|
||||
visibleArea: Float64Array
|
||||
clippedArea: Float64Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provenance and confidence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Provenance {
|
||||
factId: Uint32Array
|
||||
extractionStepId: Uint32Array
|
||||
sourceKind: Uint16Array
|
||||
sourceRef: Uint32Array
|
||||
}
|
||||
|
||||
export interface Confidence {
|
||||
factId: Uint32Array
|
||||
confidence: Float32Array
|
||||
reasonCode: Uint16Array
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Complete geometry world
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GeometryWorld {
|
||||
sceneId: number // string table index
|
||||
snapshotId: number // string table index
|
||||
env: Env
|
||||
source: Source
|
||||
strings: StringTable
|
||||
subjects: Subjects
|
||||
dom: Dom
|
||||
frames: Frames
|
||||
matrices: Matrices
|
||||
rects: Rects
|
||||
boxes: Boxes
|
||||
fragments: Fragments
|
||||
transforms: Transforms
|
||||
styles: Styles
|
||||
text: TextRuns
|
||||
topology: Topology
|
||||
scroll: Scroll
|
||||
clipping: Clipping
|
||||
paint: Paint
|
||||
visibility: Visibility
|
||||
provenance: Provenance
|
||||
confidence: Confidence
|
||||
|
||||
// Domain indexes for deterministic first-order logic enumeration (V1.1)
|
||||
selectorIndex: Map<string, number[]>
|
||||
ancestorIndex: Map<number, number[]>
|
||||
lineBoxIndex: Map<number, number[]>
|
||||
textRunIndex: Map<number, number[]>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index structures for fast lookups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorldIndex {
|
||||
// subjectId -> index in subjects.ids
|
||||
subjectById: Map<number, number>
|
||||
// domNodeId -> index in dom.nodeId
|
||||
domNodeById: Map<number, number>
|
||||
// frameId -> index in frames.frameId
|
||||
frameById: Map<number, number>
|
||||
// boxId -> index in boxes.boxId
|
||||
boxById: Map<number, number>
|
||||
// fragmentId -> index in fragments.fragmentId
|
||||
fragmentById: Map<number, number>
|
||||
// rectId -> index in rects.rectId
|
||||
rectById: Map<number, number>
|
||||
// transformId -> index in transforms.transformId
|
||||
transformById: Map<number, number>
|
||||
// subjectId -> index in styles.subjectId
|
||||
styleBySubjectId: Map<number, number>
|
||||
// subjectId -> indices in text.subjectId
|
||||
textRunsBySubjectId: Map<number, number[]>
|
||||
// subjectId -> index in visibility.subjectId
|
||||
visibilityBySubjectId: Map<number, number>
|
||||
}
|
||||
|
||||
export function buildWorldIndex(world: GeometryWorld): WorldIndex {
|
||||
const subjectById = new Map<number, number>()
|
||||
for (let i = 0; i < world.subjects.ids.length; i++) {
|
||||
subjectById.set(world.subjects.ids[i], i)
|
||||
}
|
||||
|
||||
const domNodeById = new Map<number, number>()
|
||||
for (let i = 0; i < world.dom.nodeId.length; i++) {
|
||||
domNodeById.set(world.dom.nodeId[i], i)
|
||||
}
|
||||
|
||||
const frameById = new Map<number, number>()
|
||||
for (let i = 0; i < world.frames.frameId.length; i++) {
|
||||
frameById.set(world.frames.frameId[i], i)
|
||||
}
|
||||
|
||||
const boxById = new Map<number, number>()
|
||||
for (let i = 0; i < world.boxes.boxId.length; i++) {
|
||||
boxById.set(world.boxes.boxId[i], i)
|
||||
}
|
||||
|
||||
const fragmentById = new Map<number, number>()
|
||||
for (let i = 0; i < world.fragments.fragmentId.length; i++) {
|
||||
fragmentById.set(world.fragments.fragmentId[i], i)
|
||||
}
|
||||
|
||||
const rectById = new Map<number, number>()
|
||||
for (let i = 0; i < world.rects.rectId.length; i++) {
|
||||
rectById.set(world.rects.rectId[i], i)
|
||||
}
|
||||
|
||||
const transformById = new Map<number, number>()
|
||||
for (let i = 0; i < world.transforms.transformId.length; i++) {
|
||||
transformById.set(world.transforms.transformId[i], i)
|
||||
}
|
||||
|
||||
const styleBySubjectId = new Map<number, number>()
|
||||
for (let i = 0; i < world.styles.subjectId.length; i++) {
|
||||
styleBySubjectId.set(world.styles.subjectId[i], i)
|
||||
}
|
||||
|
||||
const textRunsBySubjectId = new Map<number, number[]>()
|
||||
for (let i = 0; i < world.text.runId.length; i++) {
|
||||
const sid = world.text.subjectId[i]
|
||||
const arr = textRunsBySubjectId.get(sid) ?? []
|
||||
arr.push(i)
|
||||
textRunsBySubjectId.set(sid, arr)
|
||||
}
|
||||
|
||||
const visibilityBySubjectId = new Map<number, number>()
|
||||
for (let i = 0; i < world.visibility.subjectId.length; i++) {
|
||||
visibilityBySubjectId.set(world.visibility.subjectId[i], i)
|
||||
}
|
||||
|
||||
return {
|
||||
subjectById,
|
||||
domNodeById,
|
||||
frameById,
|
||||
boxById,
|
||||
fragmentById,
|
||||
rectById,
|
||||
transformById,
|
||||
styleBySubjectId,
|
||||
textRunsBySubjectId,
|
||||
visibilityBySubjectId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema":"../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
"packageManager":"npm",
|
||||
"reporters":["html","clear-text","progress"],
|
||||
"testRunner":"command",
|
||||
"commandRunner":{
|
||||
"command":"npm run build && npm test"
|
||||
},
|
||||
"coverageAnalysis":"off",
|
||||
"mutate":["src/**/*.ts","!src/**/*.test.ts"],
|
||||
"thresholds":{
|
||||
"high":80,
|
||||
"low":60,
|
||||
"break":50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {},
|
||||
"composite": false,
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user