Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

208 changes: 208 additions & 0 deletions src/canonicalization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Shared canonicalization utilities for snapshot comparison and indexing.
*
* This module provides consistent normalization functions used by both:
* - tracing/indexer.ts (for computing stable digests)
* - snapshot-diff.ts (for computing diff_status labels)
*
* By sharing these helpers, we ensure consistent behavior:
* - Same text normalization (whitespace, case, length)
* - Same bbox rounding (2px precision)
* - Same change detection thresholds
*/

export interface BBox {
x: number;
y: number;
width: number;
height: number;
}

export interface VisualCues {
is_primary?: boolean;
is_clickable?: boolean;
}

export interface ElementData {
id?: number;
role?: string;
text?: string | null;
bbox?: BBox;
visual_cues?: VisualCues;
is_primary?: boolean;
is_clickable?: boolean;
}

export interface CanonicalElement {
id: number | undefined;
role: string;
text_norm: string;
bbox: BBox;
is_primary: boolean;
is_clickable: boolean;
}

/**
* Normalize text for canonical comparison.
*
* Transforms:
* - Trims leading/trailing whitespace
* - Collapses internal whitespace to single spaces
* - Lowercases
* - Caps length
*
* @param text - Input text (may be undefined/null)
* @param maxLen - Maximum length to retain (default: 80)
* @returns Normalized text string (empty string if input is falsy)
*
* @example
* normalizeText(" Hello World ") // "hello world"
* normalizeText(undefined) // ""
*/
export function normalizeText(text: string | undefined | null, maxLen: number = 80): string {
if (!text) return '';

// Trim and collapse whitespace
let normalized = text.split(/\s+/).join(' ').trim();

// Lowercase
normalized = normalized.toLowerCase();

// Cap length
if (normalized.length > maxLen) {
normalized = normalized.substring(0, maxLen);
}

return normalized;
}

/**
* Round bbox coordinates to reduce noise.
*
* Snaps coordinates to grid of `precision` pixels to ignore
* sub-pixel rendering differences.
*
* @param bbox - Bounding box with x, y, width, height
* @param precision - Grid size in pixels (default: 2)
* @returns Rounded bbox with integer coordinates
*
* @example
* roundBBox({x: 101, y: 203, width: 50, height: 25})
* // {x: 100, y: 202, width: 50, height: 24}
*/
export function roundBBox(bbox: Partial<BBox>, precision: number = 2): BBox {
return {
x: Math.round((bbox.x || 0) / precision) * precision,
y: Math.round((bbox.y || 0) / precision) * precision,
width: Math.round((bbox.width || 0) / precision) * precision,
height: Math.round((bbox.height || 0) / precision) * precision,
};
}

/**
* Check if two bboxes are equal within a threshold.
*
* @param bbox1 - First bounding box
* @param bbox2 - Second bounding box
* @param threshold - Maximum allowed difference in pixels (default: 5.0)
* @returns True if all bbox properties differ by less than threshold
*/
export function bboxEqual(
bbox1: Partial<BBox>,
bbox2: Partial<BBox>,
threshold: number = 5.0
): boolean {
return (
Math.abs((bbox1.x || 0) - (bbox2.x || 0)) <= threshold &&
Math.abs((bbox1.y || 0) - (bbox2.y || 0)) <= threshold &&
Math.abs((bbox1.width || 0) - (bbox2.width || 0)) <= threshold &&
Math.abs((bbox1.height || 0) - (bbox2.height || 0)) <= threshold
);
}

/**
* Check if two bboxes differ beyond the threshold.
*
* This is the inverse of bboxEqual, provided for semantic clarity
* in diff detection code.
*
* @param bbox1 - First bounding box
* @param bbox2 - Second bounding box
* @param threshold - Maximum allowed difference in pixels (default: 5.0)
* @returns True if any bbox property differs by more than threshold
*/
export function bboxChanged(
bbox1: Partial<BBox>,
bbox2: Partial<BBox>,
threshold: number = 5.0
): boolean {
return !bboxEqual(bbox1, bbox2, threshold);
}

/**
* Create canonical representation of an element for comparison/hashing.
*
* Extracts and normalizes the fields that matter for identity:
* - id, role, normalized text, rounded bbox
* - is_primary, is_clickable from visual_cues
*
* @param elem - Raw element object
* @returns Canonical element object with normalized fields
*/
export function canonicalizeElement(elem: ElementData): CanonicalElement {
// Extract is_primary and is_clickable from visual_cues if present
const visualCues = elem.visual_cues || {};
const isPrimary =
typeof visualCues === 'object' && visualCues !== null
? visualCues.is_primary || false
: elem.is_primary || false;
const isClickable =
typeof visualCues === 'object' && visualCues !== null
? visualCues.is_clickable || false
: elem.is_clickable || false;

return {
id: elem.id,
role: elem.role || '',
text_norm: normalizeText(elem.text),
bbox: roundBBox(elem.bbox || { x: 0, y: 0, width: 0, height: 0 }),
is_primary: isPrimary,
is_clickable: isClickable,
};
}

/**
* Check if two elements have equal content (ignoring position).
*
* Compares normalized text, role, and visual cues.
*
* @param elem1 - First element (raw or canonical)
* @param elem2 - Second element (raw or canonical)
* @returns True if content is equal after normalization
*/
export function contentEqual(elem1: ElementData, elem2: ElementData): boolean {
// Normalize both elements
const c1 = canonicalizeElement(elem1);
const c2 = canonicalizeElement(elem2);

return (
c1.role === c2.role &&
c1.text_norm === c2.text_norm &&
c1.is_primary === c2.is_primary &&
c1.is_clickable === c2.is_clickable
);
}

/**
* Check if two elements have different content (ignoring position).
*
* This is the inverse of contentEqual, provided for semantic clarity
* in diff detection code.
*
* @param elem1 - First element
* @param elem2 - Second element
* @returns True if content differs after normalization
*/
export function contentChanged(elem1: ElementData, elem2: ElementData): boolean {
return !contentEqual(elem1, elem2);
}
90 changes: 40 additions & 50 deletions src/snapshot-diff.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,43 @@
/**
* Snapshot comparison utilities for diff_status detection.
* Implements change detection logic for the Diff Overlay feature.
*
* Uses shared canonicalization helpers from canonicalization.ts to ensure
* consistent comparison behavior with tracing/indexer.ts.
*/

import { bboxChanged, contentChanged, ElementData } from './canonicalization';
import { Element, Snapshot } from './types';

export class SnapshotDiff {
/**
* Check if element's bounding box has changed significantly.
* @param el1 - First element
* @param el2 - Second element
* @param threshold - Position change threshold in pixels (default: 5.0)
* @returns True if position or size changed beyond threshold
*/
private static hasBboxChanged(el1: Element, el2: Element, threshold: number = 5.0): boolean {
return (
Math.abs(el1.bbox.x - el2.bbox.x) > threshold ||
Math.abs(el1.bbox.y - el2.bbox.y) > threshold ||
Math.abs(el1.bbox.width - el2.bbox.width) > threshold ||
Math.abs(el1.bbox.height - el2.bbox.height) > threshold
);
}

/**
* Check if element's content has changed.
* @param el1 - First element
* @param el2 - Second element
* @returns True if text, role, or visual properties changed
*/
private static hasContentChanged(el1: Element, el2: Element): boolean {
// Compare text content
if (el1.text !== el2.text) {
return true;
}

// Compare role
if (el1.role !== el2.role) {
return true;
}

// Compare visual cues
if (el1.visual_cues.is_primary !== el2.visual_cues.is_primary) {
return true;
}
if (el1.visual_cues.is_clickable !== el2.visual_cues.is_clickable) {
return true;
}

return false;
}
/**
* Convert Element to ElementData for canonicalization helpers.
*/
function elementToData(el: Element): ElementData {
return {
id: el.id,
role: el.role,
text: el.text,
bbox: {
x: el.bbox.x,
y: el.bbox.y,
width: el.bbox.width,
height: el.bbox.height,
},
visual_cues: {
is_primary: el.visual_cues.is_primary,
is_clickable: el.visual_cues.is_clickable,
},
};
}

export class SnapshotDiff {
/**
* Compare current snapshot with previous and set diff_status on elements.
*
* Uses canonicalized comparisons:
* - Text is normalized (trimmed, collapsed whitespace, lowercased)
* - Bbox is rounded to 2px grid to ignore sub-pixel differences
*
* @param current - Current snapshot
* @param previous - Previous snapshot (undefined if this is the first snapshot)
* @returns List of elements with diff_status set (includes REMOVED elements from previous)
Expand Down Expand Up @@ -83,25 +69,29 @@ export class SnapshotDiff {
diff_status: 'ADDED',
});
} else {
// Element existed before - check for changes
// Element existed before - check for changes using canonicalized comparisons
const prevEl = previousById.get(el.id)!;

const bboxChanged = SnapshotDiff.hasBboxChanged(el, prevEl);
const contentChanged = SnapshotDiff.hasContentChanged(el, prevEl);
// Convert to ElementData for canonicalization helpers
const elData = elementToData(el);
const prevElData = elementToData(prevEl);

const hasBboxChanged = bboxChanged(elData.bbox!, prevElData.bbox!);
const hasContentChanged = contentChanged(elData, prevElData);

if (bboxChanged && contentChanged) {
if (hasBboxChanged && hasContentChanged) {
// Both position and content changed - mark as MODIFIED
result.push({
...el,
diff_status: 'MODIFIED',
});
} else if (bboxChanged) {
} else if (hasBboxChanged) {
// Only position changed - mark as MOVED
result.push({
...el,
diff_status: 'MOVED',
});
} else if (contentChanged) {
} else if (hasContentChanged) {
// Only content changed - mark as MODIFIED
result.push({
...el,
Expand Down
Loading
Loading