|
| 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 | +} |
0 commit comments