v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)

This commit is contained in:
John Dvorak
2025-08-15 10:00:00 -07:00
commit 92deb689cd
321 changed files with 79170 additions and 0 deletions
+28
View File
@@ -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"
}
}
}
+306
View File
@@ -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) ?? []
}
+345
View File
@@ -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 }
}
+11
View File
@@ -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
}
+847
View File
@@ -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 []
}
}
+344
View File
@@ -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
}
+502
View File
@@ -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')
})
})
+515
View File
@@ -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
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {},
"composite": false,
"noEmitOnError": false
},
"include": [
"src/**/*"
]
}