Skip to content

Commit 3c5fa6e

Browse files
authored
Merge pull request #106 from SentienceAPI/canonical
Canonical diff status
2 parents aac529b + 4f0bfd0 commit 3c5fa6e

File tree

4 files changed

+253
-106
lines changed

4 files changed

+253
-106
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/canonicalization.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* Shared canonicalization utilities for snapshot comparison and indexing.
3+
*
4+
* This module provides consistent normalization functions used by both:
5+
* - tracing/indexer.ts (for computing stable digests)
6+
* - snapshot-diff.ts (for computing diff_status labels)
7+
*
8+
* By sharing these helpers, we ensure consistent behavior:
9+
* - Same text normalization (whitespace, case, length)
10+
* - Same bbox rounding (2px precision)
11+
* - Same change detection thresholds
12+
*/
13+
14+
export interface BBox {
15+
x: number;
16+
y: number;
17+
width: number;
18+
height: number;
19+
}
20+
21+
export interface VisualCues {
22+
is_primary?: boolean;
23+
is_clickable?: boolean;
24+
}
25+
26+
export interface ElementData {
27+
id?: number;
28+
role?: string;
29+
text?: string | null;
30+
bbox?: BBox;
31+
visual_cues?: VisualCues;
32+
is_primary?: boolean;
33+
is_clickable?: boolean;
34+
}
35+
36+
export interface CanonicalElement {
37+
id: number | undefined;
38+
role: string;
39+
text_norm: string;
40+
bbox: BBox;
41+
is_primary: boolean;
42+
is_clickable: boolean;
43+
}
44+
45+
/**
46+
* Normalize text for canonical comparison.
47+
*
48+
* Transforms:
49+
* - Trims leading/trailing whitespace
50+
* - Collapses internal whitespace to single spaces
51+
* - Lowercases
52+
* - Caps length
53+
*
54+
* @param text - Input text (may be undefined/null)
55+
* @param maxLen - Maximum length to retain (default: 80)
56+
* @returns Normalized text string (empty string if input is falsy)
57+
*
58+
* @example
59+
* normalizeText(" Hello World ") // "hello world"
60+
* normalizeText(undefined) // ""
61+
*/
62+
export function normalizeText(text: string | undefined | null, maxLen: number = 80): string {
63+
if (!text) return '';
64+
65+
// Trim and collapse whitespace
66+
let normalized = text.split(/\s+/).join(' ').trim();
67+
68+
// Lowercase
69+
normalized = normalized.toLowerCase();
70+
71+
// Cap length
72+
if (normalized.length > maxLen) {
73+
normalized = normalized.substring(0, maxLen);
74+
}
75+
76+
return normalized;
77+
}
78+
79+
/**
80+
* Round bbox coordinates to reduce noise.
81+
*
82+
* Snaps coordinates to grid of `precision` pixels to ignore
83+
* sub-pixel rendering differences.
84+
*
85+
* @param bbox - Bounding box with x, y, width, height
86+
* @param precision - Grid size in pixels (default: 2)
87+
* @returns Rounded bbox with integer coordinates
88+
*
89+
* @example
90+
* roundBBox({x: 101, y: 203, width: 50, height: 25})
91+
* // {x: 100, y: 202, width: 50, height: 24}
92+
*/
93+
export function roundBBox(bbox: Partial<BBox>, precision: number = 2): BBox {
94+
return {
95+
x: Math.round((bbox.x || 0) / precision) * precision,
96+
y: Math.round((bbox.y || 0) / precision) * precision,
97+
width: Math.round((bbox.width || 0) / precision) * precision,
98+
height: Math.round((bbox.height || 0) / precision) * precision,
99+
};
100+
}
101+
102+
/**
103+
* Check if two bboxes are equal within a threshold.
104+
*
105+
* @param bbox1 - First bounding box
106+
* @param bbox2 - Second bounding box
107+
* @param threshold - Maximum allowed difference in pixels (default: 5.0)
108+
* @returns True if all bbox properties differ by less than threshold
109+
*/
110+
export function bboxEqual(
111+
bbox1: Partial<BBox>,
112+
bbox2: Partial<BBox>,
113+
threshold: number = 5.0
114+
): boolean {
115+
return (
116+
Math.abs((bbox1.x || 0) - (bbox2.x || 0)) <= threshold &&
117+
Math.abs((bbox1.y || 0) - (bbox2.y || 0)) <= threshold &&
118+
Math.abs((bbox1.width || 0) - (bbox2.width || 0)) <= threshold &&
119+
Math.abs((bbox1.height || 0) - (bbox2.height || 0)) <= threshold
120+
);
121+
}
122+
123+
/**
124+
* Check if two bboxes differ beyond the threshold.
125+
*
126+
* This is the inverse of bboxEqual, provided for semantic clarity
127+
* in diff detection code.
128+
*
129+
* @param bbox1 - First bounding box
130+
* @param bbox2 - Second bounding box
131+
* @param threshold - Maximum allowed difference in pixels (default: 5.0)
132+
* @returns True if any bbox property differs by more than threshold
133+
*/
134+
export function bboxChanged(
135+
bbox1: Partial<BBox>,
136+
bbox2: Partial<BBox>,
137+
threshold: number = 5.0
138+
): boolean {
139+
return !bboxEqual(bbox1, bbox2, threshold);
140+
}
141+
142+
/**
143+
* Create canonical representation of an element for comparison/hashing.
144+
*
145+
* Extracts and normalizes the fields that matter for identity:
146+
* - id, role, normalized text, rounded bbox
147+
* - is_primary, is_clickable from visual_cues
148+
*
149+
* @param elem - Raw element object
150+
* @returns Canonical element object with normalized fields
151+
*/
152+
export function canonicalizeElement(elem: ElementData): CanonicalElement {
153+
// Extract is_primary and is_clickable from visual_cues if present
154+
const visualCues = elem.visual_cues || {};
155+
const isPrimary =
156+
typeof visualCues === 'object' && visualCues !== null
157+
? visualCues.is_primary || false
158+
: elem.is_primary || false;
159+
const isClickable =
160+
typeof visualCues === 'object' && visualCues !== null
161+
? visualCues.is_clickable || false
162+
: elem.is_clickable || false;
163+
164+
return {
165+
id: elem.id,
166+
role: elem.role || '',
167+
text_norm: normalizeText(elem.text),
168+
bbox: roundBBox(elem.bbox || { x: 0, y: 0, width: 0, height: 0 }),
169+
is_primary: isPrimary,
170+
is_clickable: isClickable,
171+
};
172+
}
173+
174+
/**
175+
* Check if two elements have equal content (ignoring position).
176+
*
177+
* Compares normalized text, role, and visual cues.
178+
*
179+
* @param elem1 - First element (raw or canonical)
180+
* @param elem2 - Second element (raw or canonical)
181+
* @returns True if content is equal after normalization
182+
*/
183+
export function contentEqual(elem1: ElementData, elem2: ElementData): boolean {
184+
// Normalize both elements
185+
const c1 = canonicalizeElement(elem1);
186+
const c2 = canonicalizeElement(elem2);
187+
188+
return (
189+
c1.role === c2.role &&
190+
c1.text_norm === c2.text_norm &&
191+
c1.is_primary === c2.is_primary &&
192+
c1.is_clickable === c2.is_clickable
193+
);
194+
}
195+
196+
/**
197+
* Check if two elements have different content (ignoring position).
198+
*
199+
* This is the inverse of contentEqual, provided for semantic clarity
200+
* in diff detection code.
201+
*
202+
* @param elem1 - First element
203+
* @param elem2 - Second element
204+
* @returns True if content differs after normalization
205+
*/
206+
export function contentChanged(elem1: ElementData, elem2: ElementData): boolean {
207+
return !contentEqual(elem1, elem2);
208+
}

src/snapshot-diff.ts

Lines changed: 40 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,43 @@
11
/**
22
* Snapshot comparison utilities for diff_status detection.
33
* Implements change detection logic for the Diff Overlay feature.
4+
*
5+
* Uses shared canonicalization helpers from canonicalization.ts to ensure
6+
* consistent comparison behavior with tracing/indexer.ts.
47
*/
58

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

8-
export class SnapshotDiff {
9-
/**
10-
* Check if element's bounding box has changed significantly.
11-
* @param el1 - First element
12-
* @param el2 - Second element
13-
* @param threshold - Position change threshold in pixels (default: 5.0)
14-
* @returns True if position or size changed beyond threshold
15-
*/
16-
private static hasBboxChanged(el1: Element, el2: Element, threshold: number = 5.0): boolean {
17-
return (
18-
Math.abs(el1.bbox.x - el2.bbox.x) > threshold ||
19-
Math.abs(el1.bbox.y - el2.bbox.y) > threshold ||
20-
Math.abs(el1.bbox.width - el2.bbox.width) > threshold ||
21-
Math.abs(el1.bbox.height - el2.bbox.height) > threshold
22-
);
23-
}
24-
25-
/**
26-
* Check if element's content has changed.
27-
* @param el1 - First element
28-
* @param el2 - Second element
29-
* @returns True if text, role, or visual properties changed
30-
*/
31-
private static hasContentChanged(el1: Element, el2: Element): boolean {
32-
// Compare text content
33-
if (el1.text !== el2.text) {
34-
return true;
35-
}
36-
37-
// Compare role
38-
if (el1.role !== el2.role) {
39-
return true;
40-
}
41-
42-
// Compare visual cues
43-
if (el1.visual_cues.is_primary !== el2.visual_cues.is_primary) {
44-
return true;
45-
}
46-
if (el1.visual_cues.is_clickable !== el2.visual_cues.is_clickable) {
47-
return true;
48-
}
49-
50-
return false;
51-
}
12+
/**
13+
* Convert Element to ElementData for canonicalization helpers.
14+
*/
15+
function elementToData(el: Element): ElementData {
16+
return {
17+
id: el.id,
18+
role: el.role,
19+
text: el.text,
20+
bbox: {
21+
x: el.bbox.x,
22+
y: el.bbox.y,
23+
width: el.bbox.width,
24+
height: el.bbox.height,
25+
},
26+
visual_cues: {
27+
is_primary: el.visual_cues.is_primary,
28+
is_clickable: el.visual_cues.is_clickable,
29+
},
30+
};
31+
}
5232

33+
export class SnapshotDiff {
5334
/**
5435
* Compare current snapshot with previous and set diff_status on elements.
36+
*
37+
* Uses canonicalized comparisons:
38+
* - Text is normalized (trimmed, collapsed whitespace, lowercased)
39+
* - Bbox is rounded to 2px grid to ignore sub-pixel differences
40+
*
5541
* @param current - Current snapshot
5642
* @param previous - Previous snapshot (undefined if this is the first snapshot)
5743
* @returns List of elements with diff_status set (includes REMOVED elements from previous)
@@ -83,25 +69,29 @@ export class SnapshotDiff {
8369
diff_status: 'ADDED',
8470
});
8571
} else {
86-
// Element existed before - check for changes
72+
// Element existed before - check for changes using canonicalized comparisons
8773
const prevEl = previousById.get(el.id)!;
8874

89-
const bboxChanged = SnapshotDiff.hasBboxChanged(el, prevEl);
90-
const contentChanged = SnapshotDiff.hasContentChanged(el, prevEl);
75+
// Convert to ElementData for canonicalization helpers
76+
const elData = elementToData(el);
77+
const prevElData = elementToData(prevEl);
78+
79+
const hasBboxChanged = bboxChanged(elData.bbox!, prevElData.bbox!);
80+
const hasContentChanged = contentChanged(elData, prevElData);
9181

92-
if (bboxChanged && contentChanged) {
82+
if (hasBboxChanged && hasContentChanged) {
9383
// Both position and content changed - mark as MODIFIED
9484
result.push({
9585
...el,
9686
diff_status: 'MODIFIED',
9787
});
98-
} else if (bboxChanged) {
88+
} else if (hasBboxChanged) {
9989
// Only position changed - mark as MOVED
10090
result.push({
10191
...el,
10292
diff_status: 'MOVED',
10393
});
104-
} else if (contentChanged) {
94+
} else if (hasContentChanged) {
10595
// Only content changed - mark as MODIFIED
10696
result.push({
10797
...el,

0 commit comments

Comments
 (0)