inspectra-engine 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ // Node-only Figma REST adapter. No credentials or API clients belong in inspectra/client.
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { normalizeUISnapshot } from 'shivax';
4
+ import { parseFigmaUrl } from '../compare.js';
5
+
6
+ export function validateFigmaBaseline(source) {
7
+ if (!source || source.type !== 'figma' || Object.keys(source).some(key => !['type', 'url', 'nodeId', 'unitsPerCssPixel', 'roleMappings'].includes(key))) throw new TypeError('Invalid Figma baseline');
8
+ const url = new URL(source.url);
9
+ if (url.protocol !== 'https:' || !['figma.com', 'www.figma.com'].includes(url.hostname) || url.username || url.password || url.port) throw new TypeError('An HTTPS Figma file URL is required');
10
+ const nodeId = source.nodeId ?? url.searchParams.get('node-id');
11
+ if (typeof nodeId !== 'string' || !/^I?[0-9]+[:-][0-9]+(?:;I?[0-9]+[:-][0-9]+)*$/.test(nodeId)) throw new TypeError('Select a Figma frame/node using nodeId or the URL node-id');
12
+ url.search = ''; url.searchParams.set('node-id', nodeId);
13
+ const parsed = parseFigmaUrl(url.href);
14
+ const scale = source.unitsPerCssPixel ?? 1;
15
+ if (!Number.isFinite(scale) || scale < 0.01 || scale > 100) throw new TypeError('unitsPerCssPixel must be 0.01..100');
16
+ if (source.roleMappings !== undefined && (!source.roleMappings || typeof source.roleMappings !== 'object' || Array.isArray(source.roleMappings) || Object.entries(source.roleMappings).some(([id, role]) => !/^I?[0-9]+:[0-9]+(?:;I?[0-9]+:[0-9]+)*$/.test(id) || typeof role !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(role)))) throw new TypeError('Invalid Figma roleMappings');
17
+ return { ...parsed, scale };
18
+ }
19
+ const finite = value => typeof value === 'number' && Number.isFinite(value);
20
+ const rectangle = box => box && ['x', 'y', 'width', 'height'].every(key => finite(box[key])) && box.width >= 0 && box.height >= 0;
21
+ const provenance = (kind, method, reference) => ({ sourceType: 'figma', kind, method, ...(reference ? { reference } : {}) });
22
+ const known = (value, method, reference) => ({ status: 'known', value, provenance: provenance('design', method, reference), confidence: { score: 1 } });
23
+ const unknown = (method, reference) => ({ status: 'unknown', provenance: provenance('unknown', method, reference), confidence: { score: null } });
24
+
25
+ /** Pure conversion. API data is scoped per call; unsupported evidence stays outside the strict schema. */
26
+ export function figmaNodeToSnapshot(entry, { source, scope, viewport, maxNodes = 10000, includeText = true } = {}) {
27
+ const { nodeId, fileKey, scale } = validateFigmaBaseline(source);
28
+ if (!scope || !['company', 'personal'].includes(scope.kind) || typeof scope.id !== 'string' || !scope.id.trim()) throw new TypeError('An explicit scope is required');
29
+ if (typeof includeText !== 'boolean') throw new TypeError('includeText must be boolean');
30
+ if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 10000) throw new TypeError('Invalid maxNodes');
31
+ const root = entry?.document;
32
+ if (!root || root.id !== nodeId) throw new Error('Selected Figma node was not found');
33
+ if (root.visible === false || root.opacity === 0 || !rectangle(root.absoluteBoundingBox) || !root.absoluteBoundingBox.width || !root.absoluteBoundingBox.height) throw new Error('Selected Figma node has no visible measurable bounds');
34
+ const origin = root.absoluteBoundingBox;
35
+ const nodes = [], relationships = [], metadata = { fileKey, nodeId, unitsPerCssPixel: scale, nodes: {}, diagnostics: [], components: {}, componentSets: {}, componentRelationships: [], imageRefs: [] };
36
+ const diagnostic = (id, property, message) => metadata.diagnostics.push({ nodeId: id, property, message });
37
+ const refs = new Set(), ids = new Set();
38
+ const stack = [{ node: root, parent: null, transformed: false, uniformScale: 1, opacity: 1, clip: null }];
39
+ let visited = 0;
40
+ while (stack.length) {
41
+ const { node, parent, transformed, uniformScale, opacity, clip } = stack.pop();
42
+ if (++visited > maxNodes) throw new RangeError('Figma node limit exceeded');
43
+ if (node.visible === false || node.opacity === 0) continue;
44
+ const rawBox = node.absoluteBoundingBox;
45
+ if (clip && rectangle(rawBox) && (rawBox.x + rawBox.width <= clip.x || rawBox.y + rawBox.height <= clip.y || rawBox.x >= clip.x + clip.width || rawBox.y >= clip.y + clip.height)) continue;
46
+ if (typeof node.id !== 'string' || !/^I?[0-9]+:[0-9]+(?:;I?[0-9]+:[0-9]+)*$/.test(node.id) || ids.has(node.id)) throw new Error('Invalid or duplicate Figma node id');
47
+ ids.add(node.id);
48
+ const id = node.id, detail = { name: node.name, type: node.type, parentId: parent, siblingIds: (node.children ?? []).map(child => child.id) };
49
+ metadata.nodes[id] = detail;
50
+ const vector = ['VECTOR', 'BOOLEAN_OPERATION', 'STAR', 'LINE', 'ELLIPSE', 'POLYGON'].includes(node.type);
51
+ const images = (Array.isArray(node.fills) ? node.fills : []).filter(fill => fill.visible !== false && fill.type === 'IMAGE');
52
+ for (const image of images) if (image.imageRef) refs.add(image.imageRef);
53
+ detail.fills = node.fills ?? [];
54
+ detail.images = images.map(({ imageRef, scaleMode, imageTransform, scalingFactor }) => ({ imageRef, scaleMode, imageTransform, scalingFactor }));
55
+ if (vector) detail.vector = { fillGeometry: node.fillGeometry ?? [], strokeGeometry: node.strokeGeometry ?? [], relativeTransform: node.relativeTransform, size: node.size };
56
+ if (node.componentId) {
57
+ detail.component = { id: node.componentId, properties: node.componentProperties ?? {}, overrides: node.overrides ?? [] };
58
+ metadata.components[node.componentId] = entry.components?.[node.componentId] ?? { unavailable: true };
59
+ const setId = entry.components?.[node.componentId]?.componentSetId;
60
+ if (setId) metadata.componentSets[setId] = entry.componentSets?.[setId] ?? { unavailable: true };
61
+ metadata.componentRelationships.push({ from: id, to: node.componentId, type: 'instance-of', provenance: provenance('design', 'Figma instance.componentId', id), confidence: { score: 1 } });
62
+ if (!entry.components?.[node.componentId]) diagnostic(id, 'component', 'Component definition is unavailable; instance geometry is retained');
63
+ }
64
+ let role = source.roleMappings?.[id];
65
+ if (role) detail.role = { value: role, provenance: provenance('design', 'explicit caller role mapping', id), confidence: { score: 1 } };
66
+ else {
67
+ role = id === nodeId ? 'document' : node.type === 'TEXT' ? (/^(heading|title|h[1-6])(?:[\s/_:-]|$)/i.test(node.name ?? '') ? 'heading' : 'paragraph') : images.length || vector ? 'img' : 'generic';
68
+ detail.role = { value: role, provenance: provenance('estimated', 'Figma layer type/name mapped to a presentation role; not DOM semantics', id), confidence: { score: 0.5 } };
69
+ if (node.type === 'TEXT' || node.type === 'INSTANCE') diagnostic(id, 'role', 'Figma has no guaranteed DOM semantics; use roleMappings when the inferred presentation role is unsuitable');
70
+ }
71
+ const box = rawBox;
72
+ const bounds = rectangle(box) ? known({ x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height }, 'absoluteBoundingBox minus selected frame origin', id) : unknown('Figma absoluteBoundingBox unavailable', id);
73
+ if (bounds.status === 'unknown') diagnostic(id, 'bounds', 'Bounds are unknown; coordinates were not reconstructed');
74
+ const output = { id, role, bounds };
75
+ if (node.type === 'TEXT' && includeText && typeof node.characters === 'string') {
76
+ output.text = node.characters.slice(0, 2048);
77
+ if (node.characters.length > 2048) diagnostic(id, 'text', 'Text truncated at the UISnapshot limit');
78
+ }
79
+ // Rotated/non-uniformly transformed descendants have exact bounding boxes,
80
+ // but local CSS typography/padding cannot be reconstructed from those boxes.
81
+ const matrix = node.relativeTransform;
82
+ const localScale = uniformScale * (Array.isArray(matrix) ? Math.abs(matrix[0]?.[0] ?? 1) : 1);
83
+ const accumulatedOpacity = opacity * (node.opacity ?? 1);
84
+ const distorted = transformed || (Array.isArray(matrix) && (Math.abs(matrix[0]?.[1] ?? 0) > 1e-6 || Math.abs(matrix[1]?.[0] ?? 0) > 1e-6 || (matrix[0]?.[0] ?? 1) <= 0 || (matrix[1]?.[1] ?? 1) <= 0 || Math.abs(Math.abs(matrix[0]?.[0] ?? 1) - Math.abs(matrix[1]?.[1] ?? 1)) > 1e-6));
85
+ if (distorted) diagnostic(id, 'transform', 'Rotation/skew/non-uniform transform: local typography/layout are unknown; absolute bounds remain exact');
86
+ if (node.type === 'TEXT') {
87
+ const style = node.style ?? {}, mixed = Object.values(node.styleOverrideTable ?? {}).some(override => Object.keys(override).length);
88
+ detail.unsupportedTypography = Object.fromEntries(['italic', 'textDecoration', 'textCase', 'paragraphSpacing', 'paragraphIndent', 'boundVariables'].filter(key => style[key] !== undefined).map(key => [key, style[key]]));
89
+ if (Object.keys(detail.unsupportedTypography).length) diagnostic(id, 'typography-properties', 'Additional text properties retained as evidence; UISnapshot v1 does not compare them');
90
+ detail.font = { family: style.fontFamily, postScriptName: style.fontPostScriptName, availability: 'unverified' };
91
+ diagnostic(id, 'font', 'Design font is requested metadata; browser glyph font availability/substitution cannot be established by Figma');
92
+ const typography = {};
93
+ const put = (key, value, method = `Figma style.${key}`) => { typography[key] = distorted || mixed ? unknown(mixed ? 'Mixed text styles require manual review' : 'Transformed text style unresolved', id) : value !== undefined ? known(typeof value === 'number' && key !== 'fontWeight' ? value * localScale : value, `${method}; uniform transform scale ${localScale}`, id) : unknown(`${method} unavailable`, id); };
94
+ put('fontFamily', typeof style.fontFamily === 'string' && style.fontFamily.length <= 256 ? style.fontFamily : undefined);
95
+ for (const key of ['fontSize', 'fontWeight', 'letterSpacing']) put(key, finite(style[key]) ? style[key] : undefined);
96
+ put('lineHeight', style.lineHeightUnit === 'PIXELS' && finite(style.lineHeightPx) ? style.lineHeightPx : style.lineHeightUnit === 'FONT_SIZE_%' && finite(style.lineHeightPercentFontSize) && finite(style.fontSize) ? style.fontSize * style.lineHeightPercentFontSize / 100 : undefined, 'Figma explicit pixel/percent-of-font-size line height; intrinsic remains unknown');
97
+ put('textAlign', ({ LEFT: 'left', RIGHT: 'right', CENTER: 'center', JUSTIFIED: 'justify' })[style.textAlignHorizontal]);
98
+ const fills = (node.fills ?? style.fills ?? []).filter(fill => fill.visible !== false);
99
+ const paint = fills.length === 1 && fills[0].type === 'SOLID' ? fills[0] : null;
100
+ const color = paint?.color;
101
+ put('color', color && ['r', 'g', 'b'].every(key => finite(color[key])) ? { r: color.r * 255, g: color.g * 255, b: color.b * 255, a: (color.a ?? 1) * (paint.opacity ?? 1) } : undefined, 'Figma single solid text paint; uncomposited sRGB');
102
+ if (mixed) { detail.styleOverrides = node.styleOverrideTable; diagnostic(id, 'typography', 'Mixed text styles retained as evidence; no single style is asserted'); }
103
+ if (accumulatedOpacity !== 1 || (paint?.opacity ?? 1) !== 1 || (color?.a ?? 1) !== 1) { typography.color = unknown('Translucent text requires background compositing', id); diagnostic(id, 'color', 'Uncomposited translucent paint is not compared as rendered text color'); }
104
+ output.typography = typography;
105
+ }
106
+ const layout = {};
107
+ if (['HORIZONTAL', 'VERTICAL', 'GRID'].includes(node.layoutMode)) {
108
+ const horizontal = node.layoutMode === 'HORIZONTAL';
109
+ const put = (key, value, method = `Figma auto-layout ${key}`) => { layout[key] = distorted || value === undefined ? unknown(distorted ? 'Transformed auto-layout unresolved' : `${method} unavailable or unsupported`, id) : known(typeof value === 'number' ? value * localScale : value, `${method}; uniform transform scale ${localScale}`, id); };
110
+ put('display', node.layoutMode === 'GRID' ? 'grid' : 'flex');
111
+ if (node.layoutMode !== 'GRID') put('flexDirection', horizontal ? 'row' : 'column');
112
+ for (const key of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']) put(key, finite(node[key]) ? node[key] : undefined);
113
+ const gap = finite(node.itemSpacing) && node.itemSpacing >= 0 ? node.itemSpacing : undefined;
114
+ put('rowGap', node.layoutMode === 'GRID' ? node.gridRowGap : !horizontal ? gap : node.layoutWrap === 'WRAP' ? node.counterAxisSpacing : 0);
115
+ put('columnGap', node.layoutMode === 'GRID' ? node.gridColumnGap : horizontal ? gap : node.layoutWrap === 'WRAP' ? node.counterAxisSpacing : 0);
116
+ if (node.layoutMode !== 'GRID') {
117
+ put('justifyContent', ({ MIN: 'flex-start', MAX: 'flex-end', CENTER: 'center', SPACE_BETWEEN: 'space-between' })[node.primaryAxisAlignItems]);
118
+ put('alignItems', ({ MIN: 'flex-start', MAX: 'flex-end', CENTER: 'center', BASELINE: 'baseline' })[node.counterAxisAlignItems]);
119
+ }
120
+ if ((node.itemSpacing ?? 0) < 0) diagnostic(id, 'spacing', 'Negative item spacing is overlap, not a CSS gap; gap measurement remains unknown');
121
+ if (node.layoutWrap === 'WRAP') diagnostic(id, 'layoutWrap', 'Wrap mode retained as evidence; UISnapshot has no wrap property');
122
+ detail.autoLayout = { mode: node.layoutMode, wrap: node.layoutWrap, positioning: node.layoutPositioning, primarySizing: node.primaryAxisSizingMode, counterSizing: node.counterAxisSizingMode, itemSpacing: node.itemSpacing };
123
+ output.layout = layout;
124
+ }
125
+ for (const key of ['effects', 'strokes', 'constraints', 'boundVariables', 'layoutGrids', 'isMask', 'maskType']) if (node[key] && (typeof node[key] !== 'object' || Object.keys(node[key]).length)) diagnostic(id, key, 'Property retained in evidence but not represented by UISnapshot v1');
126
+ detail.unsupported = Object.fromEntries(['effects', 'strokes', 'constraints', 'boundVariables', 'layoutGrids', 'isMask', 'maskType'].filter(key => node[key] !== undefined).map(key => [key, node[key]]));
127
+ nodes.push(output);
128
+ if (parent) relationships.push({ from: parent, to: id, type: 'parent-child', provenance: provenance('design', 'Figma children hierarchy', id), confidence: { score: 1 } });
129
+ let childClip = clip;
130
+ if (node.clipsContent && rectangle(box) && !distorted) {
131
+ const left = Math.max(clip?.x ?? box.x, box.x), top = Math.max(clip?.y ?? box.y, box.y);
132
+ childClip = { x: left, y: top, width: Math.max(0, Math.min(clip ? clip.x + clip.width : box.x + box.width, box.x + box.width) - left), height: Math.max(0, Math.min(clip ? clip.y + clip.height : box.y + box.height, box.y + box.height) - top) };
133
+ detail.clip = childClip;
134
+ }
135
+ if (node.clipsContent && distorted) diagnostic(id, 'clipping', 'Transformed clipping cannot be resolved from an axis-aligned rectangle');
136
+ for (const child of [...(node.children ?? [])].reverse()) stack.push({ node: child, parent: id, transformed: distorted, uniformScale: localScale, opacity: accumulatedOpacity, clip: childClip });
137
+ }
138
+ metadata.imageRefs = [...refs];
139
+ const snapshot = normalizeUISnapshot({ version: 1, sourceType: 'figma', scope: { kind: scope.kind, id: scope.id },
140
+ viewport: viewport ?? { width: origin.width / scale, height: origin.height / scale, devicePixelRatio: 1 },
141
+ coordinates: { unit: 'design-unit', unitsPerCssPixel: scale, origin: 'artboard' }, nodes, relationships });
142
+ if (viewport && Math.abs(origin.width / scale - viewport.width) > 0.01) diagnostic(nodeId, 'viewport', 'Selected frame width differs from the target viewport; no implicit responsive scaling was applied');
143
+ return { snapshot, metadata };
144
+ }
145
+
146
+ async function bytes(response, maxBytes) {
147
+ const chunks = []; let size = 0;
148
+ if (!response.body) throw new Error('Figma response is empty');
149
+ for await (const chunk of response.body) {
150
+ size += chunk.length;
151
+ if (size > maxBytes) throw new RangeError('Figma response exceeds resource limit');
152
+ chunks.push(Buffer.from(chunk));
153
+ }
154
+ return Buffer.concat(chunks);
155
+ }
156
+
157
+ export async function captureFigmaBaseline(source, { token = process.env.FIGMA_TOKEN, signal, timeout = 15000, fetch: request = globalThis.fetch, screenshotPath, onEvent, ...options } = {}) {
158
+ const { fileKey, nodeId, scale } = validateFigmaBaseline(source);
159
+ if (!Number.isInteger(timeout) || timeout <= 0) throw new TypeError('Figma timeout must be a positive integer');
160
+ if (typeof request !== 'function') throw new TypeError('Figma fetch must be a function');
161
+ if (typeof token !== 'string' || !token.trim()) throw new Error('FIGMA_TOKEN is required in the Node process');
162
+ const deadline = AbortSignal.timeout(timeout);
163
+ const combined = AbortSignal.any([...(signal ? [signal] : []), deadline]);
164
+ let stage = "loading the selected frame";
165
+ try {
166
+ combined.throwIfAborted();
167
+ const api = async endpoint => {
168
+ let response;
169
+ try { response = await request(`https://api.figma.com/v1/${endpoint}`, { headers: { 'X-Figma-Token': token }, signal: combined, redirect: 'error' }); }
170
+ catch { combined.throwIfAborted(); throw new Error('Figma API request failed'); }
171
+ if (!response.ok) {
172
+ await response.body?.cancel();
173
+ const labels = { 401: 'invalid or expired token', 403: 'permission denied or token lacks file_content:read', 404: 'file or node not found', 429: 'rate limit reached; retry later' };
174
+ throw Object.assign(new Error(`Figma API ${response.status}: ${labels[response.status] ?? 'request failed'}`), { status: response.status });
175
+ }
176
+ let data;
177
+ try { data = JSON.parse((await bytes(response, 16 * 1024 * 1024)).toString('utf8')); }
178
+ catch { combined.throwIfAborted(); throw new Error('Invalid or oversized Figma API response'); }
179
+ if (data.err) throw new Error('Figma API could not provide the selected node');
180
+ return data;
181
+ };
182
+ onEvent?.({ type: 'state', state: 'loading', side: 'baseline' });
183
+ const data = await api(`files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}&geometry=paths`);
184
+ onEvent?.({ type: 'state', state: 'extracting', side: 'baseline' });
185
+ const capture = figmaNodeToSnapshot(data.nodes?.[nodeId], { ...options, source });
186
+ capture.metadata.version = data.version ?? null;
187
+ onEvent?.({ type: 'snapshot', side: 'baseline', nodes: capture.snapshot.nodes.length });
188
+ // Resolve only image references present in the selected subtree; no cross-file cache.
189
+ if (capture.metadata.imageRefs.length) {
190
+ stage = "loading image references";
191
+ const fills = await api(`files/${fileKey}/images`);
192
+ capture.metadata.images = Object.fromEntries(capture.metadata.imageRefs.map(ref => [ref, { available: Boolean(fills.meta?.images?.[ref] ?? fills.images?.[ref]) }]));
193
+ for (const [ref, image] of Object.entries(capture.metadata.images)) if (!image.available) capture.metadata.diagnostics.push({ nodeId, property: 'image', message: `Image reference ${ref} is unavailable` });
194
+ }
195
+ stage = "rendering the selected frame";
196
+ const render = await api(`images/${fileKey}?ids=${encodeURIComponent(nodeId)}&format=png&scale=${Math.max(0.01, Math.min(4, 1 / scale))}&use_absolute_bounds=true${data.version ? `&version=${encodeURIComponent(data.version)}` : ''}`);
197
+ let imageUrl;
198
+ try {
199
+ imageUrl = new URL(render.images?.[nodeId]);
200
+ if (imageUrl.protocol !== 'https:' || imageUrl.username || imageUrl.password || !/(^|\.)(amazonaws\.com|figma\.com|figmausercontent\.com|cloudfront\.net)$/.test(imageUrl.hostname)) throw new Error();
201
+ } catch { throw new Error('Figma did not return a trusted render URL for the selected node'); }
202
+ stage = "downloading the frame preview";
203
+ let response;
204
+ try { response = await request(imageUrl.href, { signal: combined, redirect: 'error' }); }
205
+ catch { combined.throwIfAborted(); throw new Error('Figma preview download failed'); }
206
+ if (!response.ok) { await response.body?.cancel(); throw new Error(`Figma preview download failed (${response.status})`); }
207
+ const image = await bytes(response, 32 * 1024 * 1024);
208
+ if (image.length < 24 || !image.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || image.toString('ascii', 12, 16) !== 'IHDR') throw new Error('Figma preview is not a PNG');
209
+ const width = image.readUInt32BE(16), height = image.readUInt32BE(20);
210
+ if (!width || !height || width * height > 32 * 1024 * 1024) throw new Error('Figma preview dimensions exceed resource limit');
211
+ combined.throwIfAborted();
212
+ capture.metadata.preview = { width, height, requestedScale: Math.max(0.01, Math.min(4, 1 / scale)), semanticsIndependentOfPreview: true };
213
+ if (screenshotPath) await writeFile(screenshotPath, image, { mode: 0o600, signal: combined });
214
+ combined.throwIfAborted();
215
+ return { ...capture, preview: image };
216
+ } catch (error) {
217
+ if (signal?.aborted) throw signal.reason;
218
+ if (deadline.aborted) throw new DOMException(`Figma baseline timed out after ${timeout} ms while ${stage}. Select a smaller frame or increase INSPECTRA_CAPTURE_TIMEOUT_MS in the local runner environment.`, 'TimeoutError');
219
+ throw error;
220
+ }
221
+ }
@@ -0,0 +1,14 @@
1
+ // Optional dependencies used only by the preserved legacy image APIs.
2
+ function load(name) {
3
+ try {
4
+ return require(name);
5
+ } catch (error) {
6
+ if (error.code !== 'MODULE_NOT_FOUND' || !error.message.includes(`'${name}'`)) throw error;
7
+ throw new Error(`Legacy pixel comparison requires ${name}. Install pixelmatch@^8 and pngjs@^7 to use this optional feature.`, { cause: error });
8
+ }
9
+ }
10
+ exports.getPNG = () => load('pngjs').PNG;
11
+ exports.getPixelmatch = () => {
12
+ const module = load('pixelmatch');
13
+ return module.default || module;
14
+ };
@@ -0,0 +1,52 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { fileURLToPath } from 'node:url';
4
+ const exec = promisify(execFile);
5
+ async function run(command, args, { signal, timeout }) {
6
+ signal?.throwIfAborted();
7
+ const task = exec(command, args, { signal, timeout, maxBuffer: 4 * 1024 * 1024, detached: process.platform !== 'win32' });
8
+ const stop = () => {
9
+ // Swift may launch compiler/interpreter children; terminate only this OCR group.
10
+ if (process.platform !== 'win32' && task.child?.pid) { try { process.kill(-task.child.pid, 'SIGKILL'); } catch {} }
11
+ else task.child?.kill();
12
+ };
13
+ signal?.addEventListener('abort', stop, { once: true });
14
+ if (signal?.aborted) stop();
15
+ try { return await task; }
16
+ catch (error) { stop(); throw error; }
17
+ finally { signal?.removeEventListener('abort', stop); }
18
+ }
19
+
20
+ /** TSV words -> line observations. Confidence is provider evidence, not calibrated probability. */
21
+ export function parseTesseractTSV(tsv) {
22
+ const groups = new Map();
23
+ for (const row of tsv.trim().split(/\r?\n/).slice(1)) {
24
+ const parts = row.split('\t');
25
+ if (parts[0] !== '5' || !parts.slice(11).join('\t').trim()) continue;
26
+ const [x, y, width, height, confidence] = parts.slice(6, 11).map(Number);
27
+ if (![x, y, width, height, confidence].every(Number.isFinite) || confidence < 0) continue;
28
+ const key = parts.slice(1, 5).join(':');
29
+ const item = { text: parts.slice(11).join('\t'), bounds: { x, y, width, height }, confidence: confidence / 100 };
30
+ const group = groups.get(key) ?? []; group.push(item); groups.set(key, group);
31
+ }
32
+ return { provider: 'tesseract-local', lines: [...groups.values()].map(words => {
33
+ const x = Math.min(...words.map(w => w.bounds.x)), y = Math.min(...words.map(w => w.bounds.y));
34
+ return { text: words.map(w => w.text).join(' '), confidence: Math.min(...words.map(w => w.confidence)),
35
+ bounds: { x, y, width: Math.max(...words.map(w => w.bounds.x + w.bounds.width)) - x, height: Math.max(...words.map(w => w.bounds.y + w.bounds.height)) - y } };
36
+ }) };
37
+ }
38
+
39
+ export async function localOCR({ path, signal, timeout }) {
40
+ try {
41
+ if (process.platform === 'darwin') {
42
+ const { stdout } = await run('/usr/bin/swift', [fileURLToPath(new URL('./vision.swift', import.meta.url)), path], { signal, timeout });
43
+ return JSON.parse(stdout);
44
+ }
45
+ const { stdout } = await run('tesseract', [path, 'stdout', '-l', 'eng', '--psm', '3', 'tsv'], { signal, timeout });
46
+ return parseTesseractTSV(stdout);
47
+ } catch (error) {
48
+ signal?.throwIfAborted();
49
+ if (error.killed) throw new DOMException('Local OCR timed out', 'TimeoutError');
50
+ throw new Error('Local OCR unavailable or failed. macOS requires Swift command-line tools and Vision; other systems require tesseract with eng data. Alternatively supply pngOcr in Node.');
51
+ }
52
+ }
@@ -0,0 +1,29 @@
1
+ // Local macOS OCR. Image data never leaves this process/machine.
2
+ import Foundation
3
+ import Vision
4
+ import ImageIO
5
+
6
+ guard CommandLine.arguments.count == 2,
7
+ let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: CommandLine.arguments[1]) as CFURL, nil),
8
+ let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
9
+ FileHandle.standardError.write(Data("Unable to decode PNG for local OCR\n".utf8)); exit(1)
10
+ }
11
+ let request = VNRecognizeTextRequest()
12
+ request.recognitionLevel = .accurate
13
+ request.usesLanguageCorrection = false
14
+ let handler = VNImageRequestHandler(cgImage: image, options: [:])
15
+ do {
16
+ try handler.perform([request])
17
+ let width = Double(image.width), height = Double(image.height)
18
+ let lines: [[String: Any]] = (request.results ?? []).compactMap { observation in
19
+ guard let text = observation.topCandidates(1).first else { return nil }
20
+ let box = observation.boundingBox
21
+ return ["text": text.string, "confidence": Double(text.confidence),
22
+ "bounds": ["x": Double(box.minX) * width, "y": (1 - Double(box.maxY)) * height,
23
+ "width": Double(box.width) * width, "height": Double(box.height) * height]]
24
+ }
25
+ let output = try JSONSerialization.data(withJSONObject: ["provider": "apple-vision-local", "lines": lines])
26
+ FileHandle.standardOutput.write(output)
27
+ } catch {
28
+ FileHandle.standardError.write(Data("Local Vision OCR failed\n".utf8)); exit(1)
29
+ }
@@ -0,0 +1,53 @@
1
+ /** Single-image visual segmentation only. Never reads target pixels or performs comparison. */
2
+ export async function detectPNGRegions(page, dataURL, lines) {
3
+ return page.evaluate(async ({ dataURL, lines }) => {
4
+ const image = new Image(); image.src = dataURL; await image.decode();
5
+ const ratio = Math.min(1, 512 / image.width, 2048 / image.height);
6
+ const width = Math.max(1, Math.round(image.width * ratio)), height = Math.max(1, Math.round(image.height * ratio));
7
+ const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
8
+ const context = canvas.getContext('2d', { willReadFrequently: true }); context.fillStyle = 'white'; context.fillRect(0, 0, width, height); context.drawImage(image, 0, 0, width, height);
9
+ const pixels = context.getImageData(0, 0, width, height).data;
10
+ const colors = new Uint16Array(width * height);
11
+ for (let i = 0; i < colors.length; i++) colors[i] = (Math.round(pixels[i * 4] / 32) << 8) | (Math.round(pixels[i * 4 + 1] / 32) << 4) | Math.round(pixels[i * 4 + 2] / 32);
12
+ const seen = new Uint8Array(colors.length), queue = new Int32Array(colors.length), regions = [];
13
+ for (let start = 0; start < colors.length; start++) {
14
+ if (seen[start]) continue;
15
+ let head = 0, tail = 1, left = width, top = height, right = 0, bottom = 0;
16
+ queue[0] = start; seen[start] = 1;
17
+ const value = colors[start];
18
+ while (head < tail) {
19
+ const index = queue[head++], x = index % width, y = Math.floor(index / width);
20
+ left = Math.min(left, x); top = Math.min(top, y); right = Math.max(right, x + 1); bottom = Math.max(bottom, y + 1);
21
+ for (const next of [x > 0 ? index - 1 : -1, x + 1 < width ? index + 1 : -1, y > 0 ? index - width : -1, y + 1 < height ? index + width : -1]) {
22
+ if (next >= 0 && !seen[next] && colors[next] === value) { seen[next] = 1; queue[tail++] = next; }
23
+ }
24
+ }
25
+ const w = right - left, h = bottom - top, area = w * h, coverage = tail / area;
26
+ if (w / ratio < 28 || h / ratio < 18 || area > width * height * 0.92 || coverage < 0.35) continue;
27
+ const bounds = { x: left / ratio, y: top / ratio, width: Math.min(image.width - left / ratio, w / ratio), height: Math.min(image.height - top / ratio, h / ratio) };
28
+ const containsText = lines.some(line => line.bounds.x + line.bounds.width / 2 >= bounds.x && line.bounds.x + line.bounds.width / 2 <= bounds.x + bounds.width && line.bounds.y + line.bounds.height / 2 >= bounds.y && line.bounds.y + line.bounds.height / 2 <= bounds.y + bounds.height);
29
+ regions.push({ bounds, kind: containsText ? 'surface' : 'image', confidence: containsText ? 0.55 : 0.35, method: 'quantized connected flat-color region; image/surface identity is heuristic' });
30
+ }
31
+ // Texture regions identify possible photos/illustrations even without flat backgrounds.
32
+ const tile = 12, cols = Math.ceil(width / tile), rows = Math.ceil(height / tile), busy = new Uint8Array(cols * rows);
33
+ for (let ty = 0; ty < rows; ty++) for (let tx = 0; tx < cols; tx++) {
34
+ const cx = (tx + 0.5) * tile / ratio, cy = (ty + 0.5) * tile / ratio;
35
+ if (lines.some(line => cx >= line.bounds.x - tile / ratio && cx <= line.bounds.x + line.bounds.width + tile / ratio && cy >= line.bounds.y - tile / ratio && cy <= line.bounds.y + line.bounds.height + tile / ratio)) continue;
36
+ const palette = new Set();
37
+ for (let y = ty * tile; y < Math.min(height, (ty + 1) * tile); y += 2) for (let x = tx * tile; x < Math.min(width, (tx + 1) * tile); x += 2) palette.add(colors[y * width + x]);
38
+ if (palette.size >= 7) busy[ty * cols + tx] = 1;
39
+ }
40
+ for (let start = 0; start < busy.length; start++) {
41
+ if (!busy[start]) continue;
42
+ const stack = [start]; busy[start] = 0; let minX = cols, maxX = 0, minY = rows, maxY = 0, count = 0;
43
+ while (stack.length) {
44
+ const i = stack.pop(), x = i % cols, y = Math.floor(i / cols); count++;
45
+ minX = Math.min(minX, x); maxX = Math.max(maxX, x + 1); minY = Math.min(minY, y); maxY = Math.max(maxY, y + 1);
46
+ for (const next of [x > 0 ? i - 1 : -1, x + 1 < cols ? i + 1 : -1, y > 0 ? i - cols : -1, y + 1 < rows ? i + cols : -1]) if (next >= 0 && busy[next]) { busy[next] = 0; stack.push(next); }
47
+ }
48
+ if (count < 6 || (maxX - minX) * tile / ratio < 48 || (maxY - minY) * tile / ratio < 36) continue;
49
+ regions.push({ bounds: { x: minX * tile / ratio, y: minY * tile / ratio, width: Math.min(image.width - minX * tile / ratio, (maxX - minX) * tile / ratio), height: Math.min(image.height - minY * tile / ratio, (maxY - minY) * tile / ratio) }, kind: 'image', confidence: 0.4, method: 'connected texture tiles excluding OCR text; possible illustration/photo' });
50
+ }
51
+ return regions.sort((a, b) => b.bounds.width * b.bounds.height - a.bounds.width * a.bounds.height).slice(0, 256);
52
+ }, { dataURL, lines });
53
+ }
@@ -0,0 +1,192 @@
1
+ // Node-only PNG observation adapter. It never consumes target DOM or target pixels.
2
+ import { readFile, stat, mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { chromium } from 'playwright';
6
+ import { normalizeUISnapshot } from 'shivax';
7
+ import { localOCR } from './ocr/local.mjs';
8
+ import { detectPNGRegions } from './png-regions.mjs';
9
+
10
+ export const MAX_PNG_BYTES = 16 * 1024 * 1024;
11
+ const MAX_PIXELS = 32 * 1024 * 1024;
12
+ export function validatePNGBaseline(source, { allowPath = true } = {}) {
13
+ if (!source || source.type !== 'png' || Object.keys(source).some(key => !['type', 'path', 'data', 'viewport', 'fullPage', 'scroll'].includes(key))) throw new TypeError('Invalid PNG baseline');
14
+ if ((source.path !== undefined && typeof source.path !== 'string') || (source.data !== undefined && typeof source.data !== 'string')) throw new TypeError('PNG path/data must be strings');
15
+ if ((typeof source.path === 'string') === (typeof source.data === 'string') || (source.path !== undefined && (!allowPath || !source.path.trim()))) throw new TypeError('PNG requires exactly one local path or base64 data; local paths are not accepted over HTTP');
16
+ const v = source.viewport;
17
+ if (!v || Object.keys(v).some(key => !['width', 'height', 'devicePixelRatio'].includes(key)) || ![v.width, v.height].every(n => Number.isInteger(n) && n > 0 && n <= 16384) || !Number.isFinite(v.devicePixelRatio) || v.devicePixelRatio < 0.25 || v.devicePixelRatio > 8) throw new TypeError('PNG viewport width, height and devicePixelRatio metadata are required');
18
+ if (source.fullPage !== undefined && typeof source.fullPage !== 'boolean') throw new TypeError('PNG fullPage must be boolean');
19
+ if (source.scroll !== undefined && (!source.scroll || Object.keys(source.scroll).some(key => !['x', 'y'].includes(key)) || ![source.scroll.x, source.scroll.y].every(n => Number.isFinite(n) && n >= 0 && n <= 1000000))) throw new TypeError('Invalid PNG scroll offset');
20
+ if (source.fullPage && source.scroll && (source.scroll.x || source.scroll.y)) throw new TypeError('Full-page PNG must use a document origin at zero');
21
+ if (source.data !== undefined && source.data.length > MAX_PNG_BYTES * 4 / 3 + 32) throw new RangeError('PNG data exceeds byte limit');
22
+ }
23
+ export function pngDimensions(bytes) {
24
+ if (bytes.length < 24 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || bytes.toString('ascii', 12, 16) !== 'IHDR') throw new TypeError('A PNG image is required');
25
+ const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20);
26
+ if (!width || !height || width * height > MAX_PIXELS) throw new RangeError('PNG exceeds image dimension limit');
27
+ return { width, height };
28
+ }
29
+ const area = box => box.width * box.height;
30
+ const union = boxes => { const x = Math.min(...boxes.map(b => b.x)), y = Math.min(...boxes.map(b => b.y)); return { x, y, width: Math.max(...boxes.map(b => b.x + b.width)) - x, height: Math.max(...boxes.map(b => b.y + b.height)) - y }; };
31
+ const contains = (box, other) => other.x >= box.x - 2 && other.y >= box.y - 2 && other.x + other.width <= box.x + box.width + 2 && other.y + other.height <= box.y + box.height + 2;
32
+ const centerInside = (box, other) => other.x + other.width / 2 >= box.x && other.x + other.width / 2 <= box.x + box.width && other.y + other.height / 2 >= box.y && other.y + other.height / 2 <= box.y + box.height;
33
+ const evidence = (kind, method) => ({ sourceType: 'png', kind, method });
34
+ const estimate = (value, method, quality) => ({ status: 'known', value, provenance: evidence('estimated', method), confidence: { score: Math.max(0, Math.min(0.9, quality)) } });
35
+ const unknown = method => ({ status: 'unknown', provenance: evidence('unknown', method), confidence: { score: null } });
36
+ const median = items => [...items].sort((a, b) => a - b)[Math.floor(items.length / 2)] ?? 1;
37
+ function checkBox(box, width, height) {
38
+ return box && ['x', 'y', 'width', 'height'].every(key => Number.isFinite(box[key])) && box.x >= 0 && box.y >= 0 && box.width > 0 && box.height > 0 && box.x + box.width <= width + 2 && box.y + box.height <= height + 2;
39
+ }
40
+
41
+ // OCR boxes may straddle an image edge by a fraction of a pixel (Apple Vision).
42
+ // Allow only the same two-image-pixel tolerance used for other observed boxes,
43
+ // then intersect with the actual image. Larger/malformed observations still fail.
44
+ function ocrBox(box, width, height) {
45
+ if (!box || !['x', 'y', 'width', 'height'].every(key => Number.isFinite(box[key])) || box.width <= 0 || box.height <= 0 || box.x < -2 || box.y < -2 || box.x + box.width > width + 2 || box.y + box.height > height + 2) return undefined;
46
+ const x = Math.max(0, box.x), y = Math.max(0, box.y);
47
+ const right = Math.min(width, box.x + box.width), bottom = Math.min(height, box.y + box.height);
48
+ if (right <= x || bottom <= y) return undefined;
49
+ return { x, y, width: right - x, height: bottom - y };
50
+ }
51
+
52
+ /** Convert OCR and single-image observations; grouping is never a second cross-snapshot matcher. */
53
+ export function pngObservationsToSnapshot(observations, { source, scope, width, height, maxNodes = 2000, includeText = true } = {}) {
54
+ validatePNGBaseline(source);
55
+ if (!scope || !['company', 'personal'].includes(scope.kind) || typeof scope.id !== 'string' || !scope.id.trim()) throw new TypeError('Explicit PNG workspace scope is required');
56
+ if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 10000 || typeof includeText !== 'boolean') throw new TypeError('Invalid PNG capture limits');
57
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || width * height > MAX_PIXELS) throw new TypeError('Invalid PNG observation dimensions');
58
+ const dpr = source.viewport.devicePixelRatio;
59
+ if ((source.fullPage ? width < Math.round(source.viewport.width * dpr) - 1 : Math.abs(width - Math.round(source.viewport.width * dpr)) > 1) || (!source.fullPage && Math.abs(height - Math.round(source.viewport.height * dpr)) > 1) || (source.fullPage && height < Math.round(source.viewport.height * dpr) - 1)) throw new TypeError('PNG dimensions disagree with explicit screenshot viewport metadata');
60
+ if (!observations || typeof observations.provider !== 'string' || !observations.provider.trim() || observations.provider.length > 256 || !Array.isArray(observations.lines) || observations.lines.length > 1000 || !Array.isArray(observations.regions ?? []) || (observations.regions?.length ?? 0) > 256) throw new TypeError('Invalid or excessive PNG observations');
61
+ const diagnostics = [], nodes = [], relationships = [], details = {};
62
+ const lines = observations.lines.map((line, index) => {
63
+ if (typeof line?.text !== 'string' || line.text.length > 10000 || !Number.isFinite(line.confidence) || line.confidence < 0 || line.confidence > 1) throw new TypeError(`Invalid OCR line observation ${index}: text or confidence is malformed`);
64
+ const bounds = ocrBox(line.bounds, width, height);
65
+ if (!bounds) throw new TypeError(`Invalid OCR line observation ${index}: bounds are outside the PNG or malformed`);
66
+ if (['x', 'y', 'width', 'height'].some(key => Math.abs(bounds[key] - line.bounds[key]) > 1e-9)) diagnostics.push({ code: 'ocr-bounds-clipped', lineIndex: index, originalBounds: { ...line.bounds }, bounds: { ...bounds }, message: 'OCR bounds within two image pixels of an edge were clipped to the image; coordinates remain estimated.' });
67
+ return { ...line, bounds, text: line.text.trim(), index };
68
+ }).filter(line => line.text && line.confidence >= 0.25).sort((a, b) => a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x);
69
+ const typical = median(lines.map(line => line.bounds.height));
70
+ const offset = { x: (source.scroll?.x ?? 0) * dpr, y: (source.scroll?.y ?? 0) * dpr };
71
+ const observed = [];
72
+ const add = (role, box, quality, method, extra = {}) => {
73
+ const id = `p${observed.length}`;
74
+ observed.push({ id, role, box, quality: Math.min(quality, 0.85), method, ...extra }); return id;
75
+ };
76
+ add('document', { x: 0, y: 0, width, height }, 0.5, 'Screenshot extent is not an exact DOM document box', { kind: 'document' });
77
+ const consumed = new Set();
78
+ for (const region of observations.regions ?? []) {
79
+ if (!checkBox(region.bounds, width, height) || !Number.isFinite(region.confidence) || region.confidence < 0 || region.confidence > 1 || !['surface', 'image'].includes(region.kind) || typeof region.method !== 'string' || region.method.length > 2048) throw new TypeError('Invalid visual region observation');
80
+ const inside = lines.filter(line => centerInside(region.bounds, line.bounds));
81
+ const button = inside.length > 0 && inside.length <= 2 && inside.map(l => l.text).join(' ').length < 80 && region.bounds.height <= typical * 4 && region.bounds.width < width * 0.65 && region.bounds.width >= Math.max(...inside.map(l => l.bounds.width)) * 1.15;
82
+ if (button && inside.every(line => !consumed.has(line.index))) {
83
+ inside.forEach(line => consumed.add(line.index));
84
+ add('button', region.bounds, Math.min(region.confidence, ...inside.map(l => l.confidence), 0.55), `${region.method}; enclosed short OCR label suggests a button`, { text: inside.map(l => l.text).join(' '), kind: 'button', ocrLines: inside });
85
+ } else if (inside.length >= 2 || (inside.length && region.bounds.height > typical * 4)) {
86
+ add('generic', region.bounds, Math.min(region.confidence, 0.5), `${region.method}; containing text suggests a visual section`, { kind: 'section' });
87
+ } else if (!inside.length && !observed.some(item => item.kind === 'image' && contains(item.box, region.bounds))) add('img', region.bounds, Math.min(region.confidence, 0.4), `${region.method}; image identity cannot be proven from pixels alone`, { kind: 'image' });
88
+ }
89
+ const paragraphs = [];
90
+ for (const line of lines.filter(line => !consumed.has(line.index))) {
91
+ const previous = paragraphs.at(-1);
92
+ if (previous && line.bounds.height < typical * 1.4 && previous.every(l => l.bounds.height < typical * 1.4) && Math.abs(line.bounds.x - previous[0].bounds.x) < typical * 0.6 && line.bounds.y >= previous.at(-1).bounds.y + previous.at(-1).bounds.height && line.bounds.y - (previous.at(-1).bounds.y + previous.at(-1).bounds.height) < typical * 0.8) previous.push(line);
93
+ else paragraphs.push([line]);
94
+ }
95
+ for (const paragraph of paragraphs) {
96
+ const box = union(paragraph.map(line => line.bounds));
97
+ const heading = paragraph[0].bounds.height >= typical * 1.4;
98
+ add(heading ? 'heading' : 'paragraph', box, Math.min(...paragraph.map(l => l.confidence), heading ? 0.65 : 0.75), `${observations.provider}; OCR glyph extent; ${heading ? 'larger relative glyph height suggests heading' : 'adjacent similar OCR lines suggest paragraph'}`, { text: paragraph.map(l => l.text).join(' '), kind: heading ? 'heading' : 'paragraph', ocrLines: paragraph });
99
+ }
100
+ // Visual rows/columns are evidence about arrangement, not assertions of CSS flex/grid.
101
+ const leaves = observed.filter(item => ['heading', 'paragraph', 'button', 'image'].includes(item.kind));
102
+ for (const [axis, kind] of [['y', 'row'], ['x', 'column']]) {
103
+ const groups = [];
104
+ for (const item of [...leaves].sort((a, b) => a.box[axis] - b.box[axis])) {
105
+ let group = groups.find(items => Math.abs(items[0].box[axis] - item.box[axis]) <= typical * 0.7);
106
+ if (!group) { group = []; groups.push(group); } group.push(item);
107
+ }
108
+ for (const group of groups.filter(items => items.length > 1)) {
109
+ const box = union(group.map(item => item.box));
110
+ if (observed.some(item => contains(item.box, box) && contains(box, item.box))) continue;
111
+ add('generic', box, 0.45, `Aligned observed ${axis === 'x' ? 'left edges' : 'top edges'} suggest a ${kind}; CSS implementation unknown`, { kind, members: group.map(item => item.id) });
112
+ }
113
+ }
114
+ if (observed.length > maxNodes) throw new RangeError('PNG node limit exceeded');
115
+ for (const item of observed) {
116
+ const bounds = { ...item.box, x: item.box.x + offset.x, y: item.box.y + offset.y };
117
+ const node = { id: item.id, role: item.role, bounds: estimate(bounds, item.method, item.quality) };
118
+ if (item.text !== undefined && includeText) node.text = item.text.slice(0, 2048);
119
+ if (item.text !== undefined) node.typography = Object.fromEntries(['fontFamily', 'fontWeight', 'fontSize', 'lineHeight', 'letterSpacing', 'color'].map(key => [key, unknown(`PNG OCR does not establish reliable CSS ${key}`)]));
120
+ node.layout = Object.fromEntries(['display', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft'].map(key => [key, unknown('Visual grouping/whitespace does not establish CSS layout or margin values')]));
121
+ nodes.push(node);
122
+ details[item.id] = { kind: item.kind, role: estimate(item.role, item.method, item.quality), imageBounds: estimate(item.box, item.method, item.quality), ...(item.members ? { members: item.members } : {}), ...(item.ocrLines ? { ocr: item.ocrLines.map(line => ({ ...(includeText ? { text: line.text } : {}), bounds: line.bounds, providerConfidence: line.confidence, confidence: { score: Math.min(0.9, line.confidence) }, provenance: evidence('estimated', `${observations.provider} OCR text localization`) })) } : {}) };
123
+ if (item.id !== 'p0') {
124
+ const parent = observed.filter(candidate => candidate.id !== item.id && ['document', 'section', 'row', 'column'].includes(candidate.kind) && contains(candidate.box, item.box) && area(candidate.box) > area(item.box) * 1.05).sort((a, b) => area(a.box) - area(b.box))[0] ?? observed[0];
125
+ relationships.push({ from: parent.id, to: item.id, type: 'parent-child', provenance: evidence('estimated', 'Visual containment, not observed DOM ancestry'), confidence: { score: Math.min(parent.quality, item.quality, 0.5) } });
126
+ }
127
+ }
128
+ for (const heading of observed.filter(item => item.kind === 'heading')) {
129
+ const description = observed.filter(item => item.kind === 'paragraph' && item.box.y >= heading.box.y + heading.box.height && item.box.y - heading.box.y < typical * 7 && Math.abs(item.box.x - heading.box.x) < typical * 2).sort((a, b) => a.box.y - b.box.y)[0];
130
+ if (description) relationships.push({ from: heading.id, to: description.id, type: 'heading-description', provenance: evidence('estimated', 'Spatial heading and nearby paragraph association'), confidence: { score: 0.4 } });
131
+ }
132
+ for (const image of observed.filter(item => item.kind === 'image')) {
133
+ const text = observed.filter(item => ['heading', 'paragraph'].includes(item.kind) && Math.abs(item.box.y - image.box.y) < typical * 8).sort((a, b) => Math.abs(a.box.y - image.box.y) - Math.abs(b.box.y - image.box.y))[0];
134
+ if (text) relationships.push({ from: image.id, to: text.id, type: 'image-content', provenance: evidence('estimated', 'Nearest nearby text suggests image/content association'), confidence: { score: 0.3 } });
135
+ }
136
+ diagnostics.push({ code: 'image-inference', message: 'All PNG regions, roles and relationships are estimates and need review; OCR confidence is not a calibrated probability.' });
137
+ if (!lines.length) diagnostics.push({ code: 'no-text', message: 'OCR found no usable text. Text-free regions may remain unmatched; no text was invented.' });
138
+ const snapshot = normalizeUISnapshot({ version: 1, sourceType: 'png', scope: { kind: scope.kind, id: scope.id }, viewport: { ...source.viewport }, coordinates: { unit: 'image-px', unitsPerCssPixel: dpr, origin: 'document' }, nodes, relationships });
139
+ return { snapshot, metadata: { provider: observations.provider, image: { width, height, viewport: { ...source.viewport }, fullPage: source.fullPage ?? false, scroll: { ...(source.scroll ?? { x: 0, y: 0 }) } }, nodes: details, diagnostics, reviewStatus: 'needs_review' } };
140
+ }
141
+
142
+ export async function capturePNGBaseline(source, { scope, signal, timeout = 30000, pngOcr = localOCR, maxNodes = 2000, includeText = true, screenshotPath, onEvent } = {}) {
143
+ validatePNGBaseline(source);
144
+ if (!Number.isInteger(timeout) || timeout <= 0 || typeof pngOcr !== 'function') throw new TypeError('Invalid PNG OCR configuration');
145
+ signal?.throwIfAborted();
146
+ const combined = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeout)]);
147
+ let bytes;
148
+ if (source.path) {
149
+ const info = await stat(source.path);
150
+ if (!info.isFile()) throw new TypeError('PNG path must be a regular file');
151
+ if (info.size > MAX_PNG_BYTES) throw new RangeError('PNG exceeds byte limit');
152
+ bytes = await readFile(source.path, { signal: combined });
153
+ } else {
154
+ const text = source.data.replace(/^data:image\/png;base64,/, '');
155
+ if (!text.length || (text.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text))) throw new TypeError('PNG data must be valid base64');
156
+ bytes = Buffer.from(text, 'base64');
157
+ }
158
+ if (bytes.length > MAX_PNG_BYTES) throw new RangeError('PNG exceeds byte limit');
159
+ const { width, height } = pngDimensions(bytes);
160
+ // Check metadata before invoking OCR or launching a browser.
161
+ pngObservationsToSnapshot({ provider: 'validation', lines: [] }, { source, scope, width, height, maxNodes, includeText });
162
+ combined.throwIfAborted();
163
+ const temporary = await mkdtemp(path.join(os.tmpdir(), 'inspectra-png-'));
164
+ let browser, abort;
165
+ try {
166
+ onEvent?.({ type: 'state', state: 'loading', side: 'baseline' });
167
+ const file = path.join(temporary, 'input.png'); await writeFile(file, bytes, { mode: 0o600, signal: combined });
168
+ browser = await chromium.launch();
169
+ abort = () => { void browser.close().catch(() => {}); }; combined.addEventListener('abort', abort, { once: true });
170
+ combined.throwIfAborted();
171
+ const context = await browser.newContext({ offline: true }); const page = await context.newPage();
172
+ const dataURL = `data:image/png;base64,${bytes.toString('base64')}`;
173
+ // Decode independently of OCR; malformed PNG payloads are explicit errors.
174
+ const decoded = await page.evaluate(async data => { const image = new Image(); image.src = data; await image.decode(); return { width: image.naturalWidth, height: image.naturalHeight }; }, dataURL);
175
+ if (decoded.width !== width || decoded.height !== height) throw new TypeError('PNG decoded orientation/dimensions disagree with screenshot metadata');
176
+ onEvent?.({ type: 'state', state: 'extracting', side: 'baseline' });
177
+ let stop;
178
+ const cancelled = new Promise((_, reject) => { stop = () => reject(combined.reason); combined.addEventListener('abort', stop, { once: true }); if (combined.aborted) stop(); });
179
+ let observations;
180
+ try { observations = await Promise.race([pngOcr({ path: file, width, height, signal: combined, timeout, scope: { ...scope } }), cancelled]); }
181
+ finally { combined.removeEventListener('abort', stop); }
182
+ // Validate provider output before it enters browser evaluation.
183
+ pngObservationsToSnapshot(observations, { source, scope, width, height, maxNodes, includeText });
184
+ const regions = await detectPNGRegions(page, dataURL, observations.lines.map(line => ({ ...line, bounds: ocrBox(line.bounds, width, height) })));
185
+ const capture = pngObservationsToSnapshot({ ...observations, regions: [...(observations.regions ?? []), ...regions].slice(0, 256) }, { source, scope, width, height, maxNodes, includeText });
186
+ onEvent?.({ type: 'snapshot', side: 'baseline', nodes: capture.snapshot.nodes.length });
187
+ combined.throwIfAborted();
188
+ if (screenshotPath) await writeFile(screenshotPath, bytes, { mode: 0o600, signal: combined });
189
+ return capture;
190
+ } catch (error) { combined.throwIfAborted(); throw error; }
191
+ finally { if (abort) combined.removeEventListener('abort', abort); try { await browser?.close(); } finally { await rm(temporary, { recursive: true, force: true }); } }
192
+ }
@@ -0,0 +1,12 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ for (const file of ['node/index.mjs', 'node/dev-config.mjs', 'node/runner.mjs', 'node/job-manager.mjs', 'node/analysis-worker.mjs', 'client/index.mjs', 'bin/inspectra.mjs', 'scanner/browser-snapshot.js', 'scanner/accessibility.mjs', 'scanner/figma-snapshot.mjs', 'scanner/png-snapshot.mjs', 'scanner/png-regions.mjs', 'scanner/ocr/local.mjs', 'node/png-highlights.mjs']) {
4
+ execFileSync(process.execPath, ['--check', file], { stdio: 'inherit' });
5
+ }
6
+ await import('../node/index.mjs');
7
+ await import('../client/index.mjs');
8
+ // Client runtime must remain dependency-free; shared Shivax types are type-only.
9
+ if (/\b(?:import|require)\s*(?:\(|[{*'"(])/.test(readFileSync(new URL('../client/index.mjs', import.meta.url), 'utf8'))) {
10
+ throw new Error('Browser client must not import Node scanner dependencies');
11
+ }
12
+ console.log('Package entry points verified');