@biela.dev/devices 0.1.0
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.
- package/dist/android/index.cjs +88 -0
- package/dist/android/index.cjs.map +1 -0
- package/dist/android/index.d.cts +244 -0
- package/dist/android/index.d.ts +244 -0
- package/dist/android/index.js +3 -0
- package/dist/android/index.js.map +1 -0
- package/dist/chunk-BXBG5BY7.cjs +1302 -0
- package/dist/chunk-BXBG5BY7.cjs.map +1 -0
- package/dist/chunk-DGX4WRAK.js +1749 -0
- package/dist/chunk-DGX4WRAK.js.map +1 -0
- package/dist/chunk-OI46UKOY.cjs +1774 -0
- package/dist/chunk-OI46UKOY.cjs.map +1 -0
- package/dist/chunk-QQFC4CAP.js +1281 -0
- package/dist/chunk-QQFC4CAP.js.map +1 -0
- package/dist/contract-types-Cw1rmF3b.d.cts +199 -0
- package/dist/contract-types-Cw1rmF3b.d.ts +199 -0
- package/dist/index.cjs +1494 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +311 -0
- package/dist/index.d.ts +311 -0
- package/dist/index.js +1298 -0
- package/dist/index.js.map +1 -0
- package/dist/ios/index.cjs +104 -0
- package/dist/ios/index.cjs.map +1 -0
- package/dist/ios/index.d.cts +292 -0
- package/dist/ios/index.d.ts +292 -0
- package/dist/ios/index.js +3 -0
- package/dist/ios/index.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
import { PIXEL_9_PRO_META, PIXEL_9_PRO_XL_META, GALAXY_S25_EDGE_META, GALAXY_S25_META, GALAXY_S25_ULTRA_META, PIXEL_9_PRO_LAYOUT, PIXEL_9_PRO_XL_LAYOUT, GALAXY_S25_EDGE_LAYOUT, GALAXY_S25_LAYOUT, GALAXY_S25_ULTRA_LAYOUT } from './chunk-QQFC4CAP.js';
|
|
2
|
+
export { GALAXY_S25_EDGE_FRAME, GALAXY_S25_EDGE_LAYOUT, GALAXY_S25_EDGE_META, GALAXY_S25_FRAME, GALAXY_S25_LAYOUT, GALAXY_S25_META, GALAXY_S25_ULTRA_FRAME, GALAXY_S25_ULTRA_LAYOUT, GALAXY_S25_ULTRA_META, GalaxyS25EdgeSVG, GalaxyS25SVG, GalaxyS25UltraSVG, PIXEL_9_PRO_FRAME, PIXEL_9_PRO_LAYOUT, PIXEL_9_PRO_META, PIXEL_9_PRO_XL_FRAME, PIXEL_9_PRO_XL_LAYOUT, PIXEL_9_PRO_XL_META, Pixel9ProSVG, Pixel9ProXLSVG } from './chunk-QQFC4CAP.js';
|
|
3
|
+
import { IPHONE_SE_3_META, IPHONE_16E_META, IPHONE_16_META, IPHONE_AIR_META, IPHONE_17_PRO_META, IPHONE_17_PRO_MAX_META, IPHONE_SE_3_LAYOUT, IPHONE_16E_LAYOUT, IPHONE_16_LAYOUT, IPHONE_AIR_LAYOUT, IPHONE_17_PRO_LAYOUT, IPHONE_17_PRO_MAX_LAYOUT } from './chunk-DGX4WRAK.js';
|
|
4
|
+
export { IPHONE_16E_FRAME, IPHONE_16E_LAYOUT, IPHONE_16E_META, IPHONE_16_FRAME, IPHONE_16_LAYOUT, IPHONE_16_META, IPHONE_17_PRO_FRAME, IPHONE_17_PRO_LAYOUT, IPHONE_17_PRO_MAX_FRAME, IPHONE_17_PRO_MAX_LAYOUT, IPHONE_17_PRO_MAX_META, IPHONE_17_PRO_META, IPHONE_AIR_FRAME, IPHONE_AIR_LAYOUT, IPHONE_AIR_META, IPHONE_SE_3_FRAME, IPHONE_SE_3_LAYOUT, IPHONE_SE_3_META, IPhone16SVG, IPhone16eSVG, IPhone17ProMaxSVG, IPhone17ProSVG, IPhoneAirSVG, IPhoneSE3SVG } from './chunk-DGX4WRAK.js';
|
|
5
|
+
|
|
6
|
+
// src/custom/device-registry.ts
|
|
7
|
+
var STORAGE_KEY = "bielaframe-custom-devices";
|
|
8
|
+
var DeviceRegistry = class {
|
|
9
|
+
devices = /* @__PURE__ */ new Map();
|
|
10
|
+
storage;
|
|
11
|
+
constructor(storage) {
|
|
12
|
+
this.storage = storage ?? (typeof localStorage !== "undefined" ? localStorage : null);
|
|
13
|
+
this.load();
|
|
14
|
+
}
|
|
15
|
+
/** Register or update a custom device */
|
|
16
|
+
register(device) {
|
|
17
|
+
this.devices.set(device.meta.id, {
|
|
18
|
+
...device,
|
|
19
|
+
source: "custom",
|
|
20
|
+
registeredAt: device.registeredAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
21
|
+
});
|
|
22
|
+
this.save();
|
|
23
|
+
}
|
|
24
|
+
/** Get a custom device by ID */
|
|
25
|
+
get(deviceId) {
|
|
26
|
+
return this.devices.get(deviceId);
|
|
27
|
+
}
|
|
28
|
+
/** Get the metadata for a custom device */
|
|
29
|
+
getMeta(deviceId) {
|
|
30
|
+
return this.devices.get(deviceId)?.meta;
|
|
31
|
+
}
|
|
32
|
+
/** Get the contract for a custom device */
|
|
33
|
+
getContract(deviceId) {
|
|
34
|
+
return this.devices.get(deviceId)?.contract;
|
|
35
|
+
}
|
|
36
|
+
/** List all custom devices */
|
|
37
|
+
list() {
|
|
38
|
+
return Array.from(this.devices.values());
|
|
39
|
+
}
|
|
40
|
+
/** List all custom device IDs */
|
|
41
|
+
listIds() {
|
|
42
|
+
return Array.from(this.devices.keys());
|
|
43
|
+
}
|
|
44
|
+
/** Check if a custom device exists */
|
|
45
|
+
has(deviceId) {
|
|
46
|
+
return this.devices.has(deviceId);
|
|
47
|
+
}
|
|
48
|
+
/** Remove a custom device */
|
|
49
|
+
remove(deviceId) {
|
|
50
|
+
const deleted = this.devices.delete(deviceId);
|
|
51
|
+
if (deleted) this.save();
|
|
52
|
+
return deleted;
|
|
53
|
+
}
|
|
54
|
+
/** Remove all custom devices */
|
|
55
|
+
clear() {
|
|
56
|
+
this.devices.clear();
|
|
57
|
+
this.save();
|
|
58
|
+
}
|
|
59
|
+
/** Get the number of custom devices */
|
|
60
|
+
get size() {
|
|
61
|
+
return this.devices.size;
|
|
62
|
+
}
|
|
63
|
+
/** Export all custom devices as a JSON string */
|
|
64
|
+
exportAll() {
|
|
65
|
+
const entries = Array.from(this.devices.entries());
|
|
66
|
+
return JSON.stringify(entries, null, 2);
|
|
67
|
+
}
|
|
68
|
+
/** Import custom devices from a JSON string (merges with existing) */
|
|
69
|
+
importAll(json) {
|
|
70
|
+
const entries = JSON.parse(json);
|
|
71
|
+
const skipped = [];
|
|
72
|
+
let imported = 0;
|
|
73
|
+
for (const [id, device] of entries) {
|
|
74
|
+
if (this.devices.has(id)) {
|
|
75
|
+
skipped.push(id);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
this.devices.set(id, { ...device, source: "custom" });
|
|
79
|
+
imported++;
|
|
80
|
+
}
|
|
81
|
+
if (imported > 0) this.save();
|
|
82
|
+
return { imported, skipped };
|
|
83
|
+
}
|
|
84
|
+
/** Import and overwrite existing entries */
|
|
85
|
+
importAllOverwrite(json) {
|
|
86
|
+
const entries = JSON.parse(json);
|
|
87
|
+
for (const [id, device] of entries) {
|
|
88
|
+
this.devices.set(id, { ...device, source: "custom" });
|
|
89
|
+
}
|
|
90
|
+
this.save();
|
|
91
|
+
return entries.length;
|
|
92
|
+
}
|
|
93
|
+
// ─── Persistence ───
|
|
94
|
+
load() {
|
|
95
|
+
if (!this.storage) return;
|
|
96
|
+
try {
|
|
97
|
+
const raw = this.storage.getItem(STORAGE_KEY);
|
|
98
|
+
if (raw) {
|
|
99
|
+
const entries = JSON.parse(raw);
|
|
100
|
+
this.devices = new Map(entries);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
this.devices = /* @__PURE__ */ new Map();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
save() {
|
|
107
|
+
const json = JSON.stringify(Array.from(this.devices.entries()));
|
|
108
|
+
if (this.storage) {
|
|
109
|
+
try {
|
|
110
|
+
this.storage.setItem(STORAGE_KEY, json);
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (typeof fetch !== "undefined") {
|
|
115
|
+
fetch("/api/storage/custom-devices", {
|
|
116
|
+
method: "PUT",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: json
|
|
119
|
+
}).catch(() => {
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
var _defaultRegistry = null;
|
|
125
|
+
function getDeviceRegistry() {
|
|
126
|
+
if (!_defaultRegistry) {
|
|
127
|
+
_defaultRegistry = new DeviceRegistry();
|
|
128
|
+
}
|
|
129
|
+
return _defaultRegistry;
|
|
130
|
+
}
|
|
131
|
+
function resetDeviceRegistry() {
|
|
132
|
+
_defaultRegistry = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/registry.ts
|
|
136
|
+
var BUILTIN_DEVICES = {
|
|
137
|
+
// iPhone (latest gen per screen class)
|
|
138
|
+
"iphone-17-pro-max": IPHONE_17_PRO_MAX_META,
|
|
139
|
+
"iphone-17-pro": IPHONE_17_PRO_META,
|
|
140
|
+
"iphone-air": IPHONE_AIR_META,
|
|
141
|
+
"iphone-16": IPHONE_16_META,
|
|
142
|
+
"iphone-16e": IPHONE_16E_META,
|
|
143
|
+
"iphone-se-3": IPHONE_SE_3_META,
|
|
144
|
+
// Android
|
|
145
|
+
"galaxy-s25-ultra": GALAXY_S25_ULTRA_META,
|
|
146
|
+
"galaxy-s25": GALAXY_S25_META,
|
|
147
|
+
"galaxy-s25-edge": GALAXY_S25_EDGE_META,
|
|
148
|
+
"pixel-9-pro-xl": PIXEL_9_PRO_XL_META,
|
|
149
|
+
"pixel-9-pro": PIXEL_9_PRO_META
|
|
150
|
+
};
|
|
151
|
+
var DEVICE_LAYOUTS = {
|
|
152
|
+
// iPhone
|
|
153
|
+
"iphone-17-pro-max": IPHONE_17_PRO_MAX_LAYOUT,
|
|
154
|
+
"iphone-17-pro": IPHONE_17_PRO_LAYOUT,
|
|
155
|
+
"iphone-air": IPHONE_AIR_LAYOUT,
|
|
156
|
+
"iphone-16": IPHONE_16_LAYOUT,
|
|
157
|
+
"iphone-16e": IPHONE_16E_LAYOUT,
|
|
158
|
+
"iphone-se-3": IPHONE_SE_3_LAYOUT,
|
|
159
|
+
// Android
|
|
160
|
+
"galaxy-s25-ultra": GALAXY_S25_ULTRA_LAYOUT,
|
|
161
|
+
"galaxy-s25": GALAXY_S25_LAYOUT,
|
|
162
|
+
"galaxy-s25-edge": GALAXY_S25_EDGE_LAYOUT,
|
|
163
|
+
"pixel-9-pro-xl": PIXEL_9_PRO_XL_LAYOUT,
|
|
164
|
+
"pixel-9-pro": PIXEL_9_PRO_LAYOUT
|
|
165
|
+
};
|
|
166
|
+
function getDeviceMetadata(deviceId) {
|
|
167
|
+
const builtin = BUILTIN_DEVICES[deviceId];
|
|
168
|
+
if (builtin) return builtin;
|
|
169
|
+
const custom = getDeviceRegistry().getMeta(deviceId);
|
|
170
|
+
if (custom) return custom;
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Device "${deviceId}" not found in built-in library or custom registry. Available devices: ${getAllDeviceIds().join(", ") || "(none yet)"}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
function getAllDeviceIds() {
|
|
176
|
+
return [
|
|
177
|
+
...Object.keys(BUILTIN_DEVICES),
|
|
178
|
+
...getDeviceRegistry().listIds()
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/contract.ts
|
|
183
|
+
function deriveContentZone(screenWidth, screenHeight, safeArea) {
|
|
184
|
+
return {
|
|
185
|
+
x: safeArea.left,
|
|
186
|
+
y: safeArea.top,
|
|
187
|
+
width: screenWidth - safeArea.left - safeArea.right,
|
|
188
|
+
height: screenHeight - safeArea.top - safeArea.bottom
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function buildCSSVariables(screen, safeArea, statusBar, homeIndicator, hardwareOverlays) {
|
|
192
|
+
const vars = {
|
|
193
|
+
"--device-width": `${screen.width}px`,
|
|
194
|
+
"--device-height": `${screen.height}px`,
|
|
195
|
+
"--safe-top": `${safeArea.top}px`,
|
|
196
|
+
"--safe-bottom": `${safeArea.bottom}px`,
|
|
197
|
+
"--safe-left": `${safeArea.left}px`,
|
|
198
|
+
"--safe-right": `${safeArea.right}px`,
|
|
199
|
+
"--status-bar-height": `${statusBar.height}px`,
|
|
200
|
+
"--home-indicator-height": `${homeIndicator.height}px`,
|
|
201
|
+
"--corner-radius": `${screen.cornerRadius}px`
|
|
202
|
+
};
|
|
203
|
+
if (hardwareOverlays.type !== "none") {
|
|
204
|
+
vars["--island-width"] = `${hardwareOverlays.portrait.width}px`;
|
|
205
|
+
vars["--island-height"] = `${hardwareOverlays.portrait.height}px`;
|
|
206
|
+
}
|
|
207
|
+
return vars;
|
|
208
|
+
}
|
|
209
|
+
function buildAIPromptConstraints(contract) {
|
|
210
|
+
const sa = contract.safeArea.portrait;
|
|
211
|
+
const cz = contract.contentZone.portrait;
|
|
212
|
+
const overlay = contract.hardwareOverlays;
|
|
213
|
+
const lines = [
|
|
214
|
+
`Device: ${contract.device.name} (${contract.device.platform})`,
|
|
215
|
+
`Screen: ${contract.screen.width}\xD7${contract.screen.height} logical points`,
|
|
216
|
+
`Content Zone: ${cz.x},${cz.y} \u2192 ${cz.width}\xD7${cz.height}pt`,
|
|
217
|
+
`Safe Area: top ${sa.top}pt, bottom ${sa.bottom}pt, left ${sa.left}pt, right ${sa.right}pt`,
|
|
218
|
+
`Corner Radius: ${contract.screen.cornerRadius}pt`
|
|
219
|
+
];
|
|
220
|
+
if (overlay.type === "dynamic-island") {
|
|
221
|
+
lines.push(
|
|
222
|
+
`Hardware: Dynamic Island (pill) \u2014 ${overlay.portrait.width}\xD7${overlay.portrait.height}pt at y=${overlay.portrait.y}`
|
|
223
|
+
);
|
|
224
|
+
} else if (overlay.type === "punch-hole") {
|
|
225
|
+
lines.push(
|
|
226
|
+
`Hardware: Punch-hole camera (circle) \u2014 ${overlay.portrait.width}pt diameter at center top`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
lines.push(
|
|
230
|
+
`IMPORTANT: DO NOT render a status bar time/clock \u2014 BielaFrame renders a live clock overlay automatically.`
|
|
231
|
+
);
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
}
|
|
234
|
+
function getDeviceContract(deviceId, orientation = "portrait") {
|
|
235
|
+
const layout = DEVICE_LAYOUTS[deviceId];
|
|
236
|
+
if (!layout) {
|
|
237
|
+
const customContract = getDeviceRegistry().getContract(deviceId);
|
|
238
|
+
if (customContract) return customContract;
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Device layout "${deviceId}" not found. Available: ${Object.keys(DEVICE_LAYOUTS).join(", ") || "(none)"}`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const { meta, safeArea, hardwareOverlays, statusBar, homeIndicator, hardwareButtons } = layout;
|
|
244
|
+
const contentZone = {
|
|
245
|
+
portrait: deriveContentZone(meta.screen.width, meta.screen.height, safeArea.portrait),
|
|
246
|
+
landscape: deriveContentZone(meta.screen.height, meta.screen.width, safeArea.landscape)
|
|
247
|
+
};
|
|
248
|
+
const cssVariables = buildCSSVariables(
|
|
249
|
+
meta.screen,
|
|
250
|
+
safeArea[orientation],
|
|
251
|
+
statusBar,
|
|
252
|
+
homeIndicator,
|
|
253
|
+
hardwareOverlays
|
|
254
|
+
);
|
|
255
|
+
const partialContract = {
|
|
256
|
+
device: {
|
|
257
|
+
id: meta.id,
|
|
258
|
+
name: meta.name,
|
|
259
|
+
platform: meta.platform,
|
|
260
|
+
year: meta.year
|
|
261
|
+
},
|
|
262
|
+
screen: meta.screen,
|
|
263
|
+
safeArea,
|
|
264
|
+
hardwareOverlays,
|
|
265
|
+
statusBar,
|
|
266
|
+
homeIndicator,
|
|
267
|
+
hardwareButtons,
|
|
268
|
+
contentZone,
|
|
269
|
+
cssVariables
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
...partialContract,
|
|
273
|
+
aiPromptConstraints: buildAIPromptConstraints(partialContract)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/svg/parse-dimensions.ts
|
|
278
|
+
function parseSVGNativeDimensions(svgString) {
|
|
279
|
+
const svgTagMatch = svgString.match(/<svg\b[^>]*>/i);
|
|
280
|
+
if (!svgTagMatch) {
|
|
281
|
+
throw new Error("No <svg> element found in the provided string");
|
|
282
|
+
}
|
|
283
|
+
const svgTag = svgTagMatch[0];
|
|
284
|
+
const viewBoxMatch = svgTag.match(/viewBox\s*=\s*["']([^"']+)["']/i);
|
|
285
|
+
if (viewBoxMatch) {
|
|
286
|
+
const parts = viewBoxMatch[1].trim().split(/[\s,]+/).map(Number);
|
|
287
|
+
if (parts.length >= 4) {
|
|
288
|
+
const w = parts[2];
|
|
289
|
+
const h = parts[3];
|
|
290
|
+
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
|
|
291
|
+
return { width: w, height: h, hasViewBox: true, source: "viewBox" };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const widthMatch = svgTag.match(/\bwidth\s*=\s*["']?(\d+\.?\d*)/i);
|
|
296
|
+
const heightMatch = svgTag.match(/\bheight\s*=\s*["']?(\d+\.?\d*)/i);
|
|
297
|
+
if (widthMatch && heightMatch) {
|
|
298
|
+
const w = parseFloat(widthMatch[1]);
|
|
299
|
+
const h = parseFloat(heightMatch[1]);
|
|
300
|
+
if (w > 0 && h > 0) {
|
|
301
|
+
return { width: w, height: h, hasViewBox: false, source: "widthHeight" };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
console.warn("[BielaFrame] SVG has no viewBox or width/height. Falling back to 390\xD7844.");
|
|
305
|
+
return { width: 390, height: 844, hasViewBox: false, source: "fallback" };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/svg/normalize.ts
|
|
309
|
+
function normalizeSVGToLogicalPoints(svgString, targetWidth, targetHeight) {
|
|
310
|
+
const native = parseSVGNativeDimensions(svgString);
|
|
311
|
+
const scaleX = targetWidth / native.width;
|
|
312
|
+
const scaleY = targetHeight / native.height;
|
|
313
|
+
const nativeAspect = native.width / native.height;
|
|
314
|
+
const targetAspect = targetWidth / targetHeight;
|
|
315
|
+
const diff = Math.abs(nativeAspect - targetAspect) / targetAspect;
|
|
316
|
+
const aspectRatioWarning = diff > 0.02 ? `SVG aspect ratio (${nativeAspect.toFixed(3)}) differs from declared device (${targetAspect.toFixed(3)}) by ${(diff * 100).toFixed(1)}%. Wrong SVG?` : null;
|
|
317
|
+
if (Math.abs(scaleX - 1) < 1e-3 && Math.abs(scaleY - 1) < 1e-3) {
|
|
318
|
+
return {
|
|
319
|
+
normalizedSVG: svgString,
|
|
320
|
+
scaleFactors: { x: 1, y: 1 },
|
|
321
|
+
aspectRatioWarning,
|
|
322
|
+
wasAlreadyNormalized: true,
|
|
323
|
+
nativeDimensions: { width: native.width, height: native.height }
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
let result = svgString;
|
|
327
|
+
result = result.replace(
|
|
328
|
+
/<svg\b([^>]*)>/i,
|
|
329
|
+
(_match, attrs) => {
|
|
330
|
+
if (/viewBox/i.test(attrs)) {
|
|
331
|
+
attrs = attrs.replace(/viewBox\s*=\s*["'][^"']*["']/i, `viewBox="0 0 ${targetWidth} ${targetHeight}"`);
|
|
332
|
+
} else {
|
|
333
|
+
attrs += ` viewBox="0 0 ${targetWidth} ${targetHeight}"`;
|
|
334
|
+
}
|
|
335
|
+
if (/\bwidth\s*=/i.test(attrs)) {
|
|
336
|
+
attrs = attrs.replace(/\bwidth\s*=\s*["']?\d+\.?\d*["']?/i, `width="${targetWidth}"`);
|
|
337
|
+
} else {
|
|
338
|
+
attrs += ` width="${targetWidth}"`;
|
|
339
|
+
}
|
|
340
|
+
if (/\bheight\s*=/i.test(attrs)) {
|
|
341
|
+
attrs = attrs.replace(/\bheight\s*=\s*["']?\d+\.?\d*["']?/i, `height="${targetHeight}"`);
|
|
342
|
+
} else {
|
|
343
|
+
attrs += ` height="${targetHeight}"`;
|
|
344
|
+
}
|
|
345
|
+
return `<svg${attrs}>`;
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
const xAttrs = ["x", "cx", "width", "rx"];
|
|
349
|
+
const yAttrs = ["y", "cy", "height", "ry"];
|
|
350
|
+
const allAttrs = [...xAttrs, ...yAttrs];
|
|
351
|
+
result = result.replace(
|
|
352
|
+
/data-zone\s*=\s*["'][^"']*["'][^>]*/gi,
|
|
353
|
+
(elementContent) => {
|
|
354
|
+
let updated = elementContent;
|
|
355
|
+
for (const attr of allAttrs) {
|
|
356
|
+
const regex = new RegExp(`\\b${attr}\\s*=\\s*["'](\\d+\\.?\\d*)["']`, "gi");
|
|
357
|
+
updated = updated.replace(regex, (_, val) => {
|
|
358
|
+
const num = parseFloat(val);
|
|
359
|
+
const scale = xAttrs.includes(attr) ? scaleX : scaleY;
|
|
360
|
+
return `${attr}="${(num * scale).toFixed(2)}"`;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return updated;
|
|
364
|
+
}
|
|
365
|
+
);
|
|
366
|
+
return {
|
|
367
|
+
normalizedSVG: result,
|
|
368
|
+
scaleFactors: { x: scaleX, y: scaleY },
|
|
369
|
+
aspectRatioWarning,
|
|
370
|
+
wasAlreadyNormalized: false,
|
|
371
|
+
nativeDimensions: { width: native.width, height: native.height }
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/svg/parse-contract.ts
|
|
376
|
+
function extractZones(svgString) {
|
|
377
|
+
const zones = [];
|
|
378
|
+
const regex = /data-zone\s*=\s*["']([^"']+)["'][^>]*/gi;
|
|
379
|
+
let match;
|
|
380
|
+
while ((match = regex.exec(svgString)) !== null) {
|
|
381
|
+
const zoneName = match[1];
|
|
382
|
+
const element = match[0];
|
|
383
|
+
const x = parseAttr(element, "x") ?? parseAttr(element, "cx") ?? 0;
|
|
384
|
+
const y = parseAttr(element, "y") ?? parseAttr(element, "cy") ?? 0;
|
|
385
|
+
const width = parseAttr(element, "width") ?? 0;
|
|
386
|
+
const height = parseAttr(element, "height") ?? 0;
|
|
387
|
+
zones.push({ zone: zoneName, x, y, width, height });
|
|
388
|
+
}
|
|
389
|
+
return zones;
|
|
390
|
+
}
|
|
391
|
+
function parseAttr(element, attr) {
|
|
392
|
+
const re = new RegExp(`\\b${attr}\\s*=\\s*["'](\\d+\\.?\\d*)["']`, "i");
|
|
393
|
+
const m = element.match(re);
|
|
394
|
+
return m ? parseFloat(m[1]) : null;
|
|
395
|
+
}
|
|
396
|
+
function parseSVGToContract(normalizedSVG, options) {
|
|
397
|
+
const { meta } = options;
|
|
398
|
+
const zones = extractZones(normalizedSVG);
|
|
399
|
+
const zoneMap = new Map(zones.map((z) => [z.zone, z]));
|
|
400
|
+
const screenZone = zoneMap.get("screen-area");
|
|
401
|
+
const screenWidth = screenZone?.width ?? meta.screen.width;
|
|
402
|
+
const screenHeight = screenZone?.height ?? meta.screen.height;
|
|
403
|
+
const safeTop = zoneMap.get("safe-area-top");
|
|
404
|
+
const safeBottom = zoneMap.get("safe-area-bottom");
|
|
405
|
+
const safeLeft = zoneMap.get("safe-area-left");
|
|
406
|
+
const safeRight = zoneMap.get("safe-area-right");
|
|
407
|
+
const portraitSafeArea = {
|
|
408
|
+
top: safeTop?.height ?? (meta.platform === "ios" ? 59 : 36),
|
|
409
|
+
bottom: safeBottom?.height ?? (meta.platform === "ios" ? 34 : 0),
|
|
410
|
+
left: safeLeft?.width ?? 0,
|
|
411
|
+
right: safeRight?.width ?? 0
|
|
412
|
+
};
|
|
413
|
+
const landscapeSafeArea = {
|
|
414
|
+
top: Math.min(portraitSafeArea.top, 21),
|
|
415
|
+
bottom: Math.min(portraitSafeArea.bottom, 21),
|
|
416
|
+
left: portraitSafeArea.top,
|
|
417
|
+
right: portraitSafeArea.bottom
|
|
418
|
+
};
|
|
419
|
+
const overlayZone = zoneMap.get("hardware-overlay");
|
|
420
|
+
const overlayType = options.overlayType ?? (overlayZone ? guessOverlayType(overlayZone, meta.platform) : "none");
|
|
421
|
+
const overlayPortrait = overlayZone ? {
|
|
422
|
+
x: overlayZone.x,
|
|
423
|
+
y: overlayZone.y,
|
|
424
|
+
width: overlayZone.width,
|
|
425
|
+
height: overlayZone.height,
|
|
426
|
+
shape: options.overlayShape ?? guessOverlayShape(overlayType)
|
|
427
|
+
} : { x: 0, y: 0, width: 0, height: 0, shape: "pill" };
|
|
428
|
+
const statusBarZone = zoneMap.get("status-bar");
|
|
429
|
+
const statusBarHeight = statusBarZone?.height ?? portraitSafeArea.top;
|
|
430
|
+
const statusBarStyle = options.statusBarStyle ?? (overlayType === "dynamic-island" ? "dynamic-island" : overlayType === "notch" ? "notch" : "fullwidth");
|
|
431
|
+
const homeZone = zoneMap.get("home-indicator");
|
|
432
|
+
const homeType = options.homeIndicatorType ?? (meta.platform === "ios" ? "swipe-bar" : "gestureline");
|
|
433
|
+
const homeHeight = homeZone?.height ?? portraitSafeArea.bottom;
|
|
434
|
+
const statusBar = {
|
|
435
|
+
height: statusBarHeight,
|
|
436
|
+
style: statusBarStyle,
|
|
437
|
+
hasTime: true,
|
|
438
|
+
hasBattery: true,
|
|
439
|
+
hasSignal: true
|
|
440
|
+
};
|
|
441
|
+
const homeIndicator = {
|
|
442
|
+
type: homeType,
|
|
443
|
+
height: homeHeight,
|
|
444
|
+
width: homeZone?.width,
|
|
445
|
+
visible: homeHeight > 0
|
|
446
|
+
};
|
|
447
|
+
const contentZone = {
|
|
448
|
+
portrait: deriveContentZone(screenWidth, screenHeight, portraitSafeArea),
|
|
449
|
+
landscape: deriveContentZone(screenHeight, screenWidth, landscapeSafeArea)
|
|
450
|
+
};
|
|
451
|
+
const cssVariables = buildCSSVariables(
|
|
452
|
+
meta.screen,
|
|
453
|
+
portraitSafeArea,
|
|
454
|
+
statusBar,
|
|
455
|
+
homeIndicator,
|
|
456
|
+
{ type: overlayType, portrait: overlayPortrait }
|
|
457
|
+
);
|
|
458
|
+
const partialContract = {
|
|
459
|
+
device: {
|
|
460
|
+
id: meta.id,
|
|
461
|
+
name: meta.name,
|
|
462
|
+
platform: meta.platform,
|
|
463
|
+
year: meta.year
|
|
464
|
+
},
|
|
465
|
+
screen: meta.screen,
|
|
466
|
+
safeArea: {
|
|
467
|
+
portrait: portraitSafeArea,
|
|
468
|
+
landscape: landscapeSafeArea
|
|
469
|
+
},
|
|
470
|
+
hardwareOverlays: {
|
|
471
|
+
type: overlayType,
|
|
472
|
+
portrait: overlayPortrait
|
|
473
|
+
},
|
|
474
|
+
statusBar,
|
|
475
|
+
homeIndicator,
|
|
476
|
+
contentZone,
|
|
477
|
+
cssVariables
|
|
478
|
+
};
|
|
479
|
+
return {
|
|
480
|
+
...partialContract,
|
|
481
|
+
aiPromptConstraints: buildAIPromptConstraints(partialContract)
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function guessOverlayType(zone, platform) {
|
|
485
|
+
if (platform === "ios") {
|
|
486
|
+
if (zone.width > zone.height * 2) return "dynamic-island";
|
|
487
|
+
if (zone.width > zone.height) return "notch";
|
|
488
|
+
return "dynamic-island";
|
|
489
|
+
}
|
|
490
|
+
if (Math.abs(zone.width - zone.height) < 4) return "punch-hole";
|
|
491
|
+
return "pill-cutout";
|
|
492
|
+
}
|
|
493
|
+
function guessOverlayShape(type) {
|
|
494
|
+
switch (type) {
|
|
495
|
+
case "dynamic-island":
|
|
496
|
+
return "pill";
|
|
497
|
+
case "notch":
|
|
498
|
+
return "rectangle";
|
|
499
|
+
case "punch-hole":
|
|
500
|
+
return "circle";
|
|
501
|
+
case "pill-cutout":
|
|
502
|
+
return "pill";
|
|
503
|
+
default:
|
|
504
|
+
return "pill";
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/svg/validate.ts
|
|
509
|
+
function validateNormalizedSVG(result, targetWidth, targetHeight) {
|
|
510
|
+
const warnings = [];
|
|
511
|
+
const errors = [];
|
|
512
|
+
if (result.aspectRatioWarning) {
|
|
513
|
+
warnings.push(result.aspectRatioWarning);
|
|
514
|
+
}
|
|
515
|
+
const maxScale = Math.max(result.scaleFactors.x, result.scaleFactors.y);
|
|
516
|
+
if (maxScale > 5) {
|
|
517
|
+
warnings.push(
|
|
518
|
+
`Scale factor is ${maxScale.toFixed(1)}x \u2014 the SVG may be authored at a much larger coordinate space than expected. Verify the SVG dimensions.`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
const minScale = Math.min(result.scaleFactors.x, result.scaleFactors.y);
|
|
522
|
+
if (minScale > 0 && minScale < 0.1) {
|
|
523
|
+
warnings.push(
|
|
524
|
+
`Scale factor is ${minScale.toFixed(3)}x \u2014 the SVG appears to be very small. Verify you're using the correct export size.`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (result.nativeDimensions.width <= 0 || result.nativeDimensions.height <= 0) {
|
|
528
|
+
errors.push("SVG has zero or negative dimensions. Check the viewBox or width/height attributes.");
|
|
529
|
+
}
|
|
530
|
+
const zones = extractZones(result.normalizedSVG);
|
|
531
|
+
const zoneNames = new Set(zones.map((z) => z.zone));
|
|
532
|
+
if (!zoneNames.has("screen-area")) {
|
|
533
|
+
warnings.push(
|
|
534
|
+
'No "screen-area" data-zone found. The screen region will use the full device dimensions.'
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (targetWidth < 100 || targetHeight < 100) {
|
|
538
|
+
errors.push(
|
|
539
|
+
`Target dimensions ${targetWidth}\xD7${targetHeight} are unusually small. Expected logical device dimensions (e.g., 390\xD7844).`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
if (targetWidth > 1500 || targetHeight > 3e3) {
|
|
543
|
+
warnings.push(
|
|
544
|
+
`Target dimensions ${targetWidth}\xD7${targetHeight} are unusually large. Expected logical points, not physical pixels.`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (result.scaleFactors.x > 0 && result.scaleFactors.y > 0) {
|
|
548
|
+
const scaleDiff = Math.abs(result.scaleFactors.x - result.scaleFactors.y) / Math.max(result.scaleFactors.x, result.scaleFactors.y);
|
|
549
|
+
if (scaleDiff > 0.05) {
|
|
550
|
+
warnings.push(
|
|
551
|
+
`Non-uniform scaling: X=${result.scaleFactors.x.toFixed(3)}, Y=${result.scaleFactors.y.toFixed(3)}. The SVG aspect ratio may not match the target device.`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
passed: errors.length === 0,
|
|
557
|
+
warnings,
|
|
558
|
+
errors
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/svg/scope-ids.ts
|
|
563
|
+
function scopeSVGIds(svgString, deviceId) {
|
|
564
|
+
const ids = [];
|
|
565
|
+
const idRegex = /\bid\s*=\s*["']([^"']+)["']/gi;
|
|
566
|
+
let match;
|
|
567
|
+
while ((match = idRegex.exec(svgString)) !== null) {
|
|
568
|
+
ids.push(match[1]);
|
|
569
|
+
}
|
|
570
|
+
if (ids.length === 0) return svgString;
|
|
571
|
+
let result = svgString;
|
|
572
|
+
const prefix = `${deviceId}__`;
|
|
573
|
+
for (const id of ids) {
|
|
574
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
575
|
+
result = result.replace(
|
|
576
|
+
new RegExp(`\\bid\\s*=\\s*["']${escapedId}["']`, "g"),
|
|
577
|
+
`id="${prefix}${id}"`
|
|
578
|
+
);
|
|
579
|
+
result = result.replace(
|
|
580
|
+
new RegExp(`url\\(\\s*#${escapedId}\\s*\\)`, "g"),
|
|
581
|
+
`url(#${prefix}${id})`
|
|
582
|
+
);
|
|
583
|
+
result = result.replace(
|
|
584
|
+
new RegExp(`href\\s*=\\s*["']#${escapedId}["']`, "g"),
|
|
585
|
+
`href="#${prefix}${id}"`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/svg/auto-detect-zones.ts
|
|
592
|
+
var ZONE_KEYWORDS = [
|
|
593
|
+
{
|
|
594
|
+
type: "screen-area",
|
|
595
|
+
keywords: ["screen", "display", "viewport", "content-area", "screen-area", "screenarea"],
|
|
596
|
+
highConfidence: ["screen", "screen-area", "display"]
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
type: "hardware-overlay",
|
|
600
|
+
keywords: [
|
|
601
|
+
"island",
|
|
602
|
+
"dynamic-island",
|
|
603
|
+
"dynamicisland",
|
|
604
|
+
"dynamic_island",
|
|
605
|
+
"notch",
|
|
606
|
+
"cutout",
|
|
607
|
+
"punch-hole",
|
|
608
|
+
"punchhole",
|
|
609
|
+
"punch_hole",
|
|
610
|
+
"camera",
|
|
611
|
+
"faceid",
|
|
612
|
+
"face-id",
|
|
613
|
+
"truedepth",
|
|
614
|
+
"sensor",
|
|
615
|
+
"pill",
|
|
616
|
+
"camera-housing"
|
|
617
|
+
],
|
|
618
|
+
highConfidence: ["island", "dynamic-island", "notch", "cutout", "punch-hole"]
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
type: "safe-area-top",
|
|
622
|
+
keywords: ["safe-area-top", "safe-top", "safeareatop", "status-area", "top-inset"],
|
|
623
|
+
highConfidence: ["safe-area-top", "safe-top"]
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
type: "safe-area-bottom",
|
|
627
|
+
keywords: ["safe-area-bottom", "safe-bottom", "safeareabottom", "bottom-inset"],
|
|
628
|
+
highConfidence: ["safe-area-bottom", "safe-bottom"]
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
type: "status-bar",
|
|
632
|
+
keywords: ["status-bar", "statusbar", "status_bar", "status"],
|
|
633
|
+
highConfidence: ["status-bar", "statusbar"]
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
type: "home-indicator",
|
|
637
|
+
keywords: [
|
|
638
|
+
"home-indicator",
|
|
639
|
+
"homeindicator",
|
|
640
|
+
"home_indicator",
|
|
641
|
+
"home-bar",
|
|
642
|
+
"homebar",
|
|
643
|
+
"swipe-bar",
|
|
644
|
+
"swipebar",
|
|
645
|
+
"gesture-bar",
|
|
646
|
+
"gesturebar",
|
|
647
|
+
"bottom-bar"
|
|
648
|
+
],
|
|
649
|
+
highConfidence: ["home-indicator", "home-bar", "swipe-bar", "gesture-bar"]
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
type: "hardware-button",
|
|
653
|
+
keywords: [
|
|
654
|
+
"volume-up",
|
|
655
|
+
"volume_up",
|
|
656
|
+
"volumeup",
|
|
657
|
+
"vol-up",
|
|
658
|
+
"volume-down",
|
|
659
|
+
"volume_down",
|
|
660
|
+
"volumedown",
|
|
661
|
+
"vol-down",
|
|
662
|
+
"power",
|
|
663
|
+
"power-button",
|
|
664
|
+
"side-button",
|
|
665
|
+
"sidebutton",
|
|
666
|
+
"action-button",
|
|
667
|
+
"action_button",
|
|
668
|
+
"actionbutton",
|
|
669
|
+
"mute",
|
|
670
|
+
"silent",
|
|
671
|
+
"ringer",
|
|
672
|
+
"hardware-button",
|
|
673
|
+
"hardwarebutton",
|
|
674
|
+
"hw-button"
|
|
675
|
+
],
|
|
676
|
+
highConfidence: ["volume-up", "volume-down", "power", "action-button", "side-button"]
|
|
677
|
+
}
|
|
678
|
+
];
|
|
679
|
+
function autoDetectZones(svgString) {
|
|
680
|
+
const nameBasedZones = detectByNames(svgString);
|
|
681
|
+
if (nameBasedZones.length > 0) return nameBasedZones;
|
|
682
|
+
const analysis = analyzeSVGGeometry(svgString);
|
|
683
|
+
return analysis.zones;
|
|
684
|
+
}
|
|
685
|
+
function analyzeSVGGeometry(svgString) {
|
|
686
|
+
const rects = extractAllRects(svgString);
|
|
687
|
+
const viewBox = extractViewBox(svgString);
|
|
688
|
+
const svgW = viewBox?.w ?? 0;
|
|
689
|
+
const svgH = viewBox?.h ?? 0;
|
|
690
|
+
const svgArea = svgW * svgH;
|
|
691
|
+
if (svgArea === 0) {
|
|
692
|
+
return { zones: [], phoneBody: null, sideButtons: [], allRects: rects };
|
|
693
|
+
}
|
|
694
|
+
const phoneBody = findPhoneBody(rects, svgW, svgH);
|
|
695
|
+
let screenRect = findScreenArea(rects, phoneBody, svgW, svgH);
|
|
696
|
+
if (!screenRect) {
|
|
697
|
+
const pathCutout = findPathCutoutScreen(svgString, svgW, svgH);
|
|
698
|
+
if (pathCutout) {
|
|
699
|
+
screenRect = pathCutout;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (!screenRect && phoneBody) {
|
|
703
|
+
const insetRatio = 0.043;
|
|
704
|
+
const insetX = Math.round(phoneBody.width * insetRatio);
|
|
705
|
+
const insetY = Math.round(phoneBody.height * (insetRatio * 0.5));
|
|
706
|
+
screenRect = {
|
|
707
|
+
x: phoneBody.x + insetX,
|
|
708
|
+
y: phoneBody.y + insetY,
|
|
709
|
+
width: phoneBody.width - insetX * 2,
|
|
710
|
+
height: phoneBody.height - insetY * 2,
|
|
711
|
+
rx: Math.max(phoneBody.rx - insetX, 20),
|
|
712
|
+
fill: "synthetic",
|
|
713
|
+
id: null
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const islandRect = findDynamicIsland(rects, screenRect);
|
|
717
|
+
const sideButtons = findSideButtons(rects, phoneBody);
|
|
718
|
+
const zones = [];
|
|
719
|
+
const now = Date.now();
|
|
720
|
+
if (screenRect) {
|
|
721
|
+
const isSynthetic = screenRect.fill === "synthetic";
|
|
722
|
+
zones.push({
|
|
723
|
+
id: `auto-screen-area-${now}`,
|
|
724
|
+
type: "screen-area",
|
|
725
|
+
sourceLabel: isSynthetic ? "geometric: inferred from body" : "geometric: largest white rect",
|
|
726
|
+
matchedKeyword: "geometry",
|
|
727
|
+
confidence: isSynthetic ? "medium" : "high",
|
|
728
|
+
x: screenRect.x,
|
|
729
|
+
y: screenRect.y,
|
|
730
|
+
width: screenRect.width,
|
|
731
|
+
height: screenRect.height,
|
|
732
|
+
rx: screenRect.rx > 0 ? screenRect.rx : void 0
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (islandRect) {
|
|
736
|
+
zones.push({
|
|
737
|
+
id: `auto-hardware-overlay-${now}`,
|
|
738
|
+
type: "hardware-overlay",
|
|
739
|
+
sourceLabel: "geometric: pill near top",
|
|
740
|
+
matchedKeyword: "geometry",
|
|
741
|
+
confidence: "medium",
|
|
742
|
+
x: islandRect.x,
|
|
743
|
+
y: islandRect.y,
|
|
744
|
+
width: islandRect.width,
|
|
745
|
+
height: islandRect.height
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
zones,
|
|
750
|
+
phoneBody: phoneBody ? { x: phoneBody.x, y: phoneBody.y, width: phoneBody.width, height: phoneBody.height, rx: phoneBody.rx } : null,
|
|
751
|
+
sideButtons: sideButtons.map((r) => ({ x: r.x, y: r.y, width: r.width, height: r.height })),
|
|
752
|
+
allRects: rects
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function findPhoneBody(rects, svgW, svgH) {
|
|
756
|
+
const svgArea = svgW * svgH;
|
|
757
|
+
const candidates = rects.filter((r) => {
|
|
758
|
+
const area = r.width * r.height;
|
|
759
|
+
if (area < svgArea * 0.3) return false;
|
|
760
|
+
if (r.rx < 20) return false;
|
|
761
|
+
if (r.height < r.width * 1.3) return false;
|
|
762
|
+
return true;
|
|
763
|
+
}).sort((a, b) => b.width * b.height - a.width * a.height);
|
|
764
|
+
return candidates[0] ?? null;
|
|
765
|
+
}
|
|
766
|
+
function findScreenArea(rects, phoneBody, svgW, svgH) {
|
|
767
|
+
const refRect = phoneBody ?? { x: 0, y: 0, width: svgW, height: svgH };
|
|
768
|
+
const candidates = rects.filter((r) => {
|
|
769
|
+
if (!isInsideRect(r, refRect, 0.05)) return false;
|
|
770
|
+
const bodyArea = refRect.width * refRect.height;
|
|
771
|
+
const area = r.width * r.height;
|
|
772
|
+
if (area < bodyArea * 0.5) return false;
|
|
773
|
+
if (area >= bodyArea * 0.98) return false;
|
|
774
|
+
if (!isLightFill(r.fill)) return false;
|
|
775
|
+
if (r.rx < 10) return false;
|
|
776
|
+
return true;
|
|
777
|
+
}).sort((a, b) => b.width * b.height - a.width * a.height);
|
|
778
|
+
if (candidates[0]) return candidates[0];
|
|
779
|
+
const fallbackCandidates = rects.filter((r) => {
|
|
780
|
+
if (!isInsideRect(r, refRect, 0.05)) return false;
|
|
781
|
+
const bodyArea = refRect.width * refRect.height;
|
|
782
|
+
const area = r.width * r.height;
|
|
783
|
+
if (area < bodyArea * 0.5) return false;
|
|
784
|
+
if (area >= bodyArea * 0.98) return false;
|
|
785
|
+
if (r.rx < 10) return false;
|
|
786
|
+
if (isDarkFill(r.fill) && r.fill !== "none" && r.fill !== null) return false;
|
|
787
|
+
return true;
|
|
788
|
+
}).sort((a, b) => b.width * b.height - a.width * a.height);
|
|
789
|
+
return fallbackCandidates[0] ?? null;
|
|
790
|
+
}
|
|
791
|
+
function findDynamicIsland(rects, screenRect) {
|
|
792
|
+
if (!screenRect) return null;
|
|
793
|
+
const candidates = rects.filter((r) => {
|
|
794
|
+
if (r.width > screenRect.width * 0.5) return false;
|
|
795
|
+
if (r.width < screenRect.width * 0.05) return false;
|
|
796
|
+
if (r.width <= r.height) return false;
|
|
797
|
+
const ratio = r.width / r.height;
|
|
798
|
+
if (ratio < 1.5 || ratio > 8) return false;
|
|
799
|
+
if (r.rx < 5) return false;
|
|
800
|
+
const topThreshold = screenRect.y + screenRect.height * 0.1;
|
|
801
|
+
if (r.y > topThreshold) return false;
|
|
802
|
+
if (r.y < screenRect.y) return false;
|
|
803
|
+
const rCenterX = r.x + r.width / 2;
|
|
804
|
+
const screenCenterX = screenRect.x + screenRect.width / 2;
|
|
805
|
+
const maxOffCenter = screenRect.width * 0.25;
|
|
806
|
+
if (Math.abs(rCenterX - screenCenterX) > maxOffCenter) return false;
|
|
807
|
+
if (!isDarkFill(r.fill)) return false;
|
|
808
|
+
return true;
|
|
809
|
+
}).sort((a, b) => b.width * b.height - a.width * a.height);
|
|
810
|
+
return candidates[0] ?? null;
|
|
811
|
+
}
|
|
812
|
+
function findSideButtons(rects, phoneBody) {
|
|
813
|
+
if (!phoneBody) return [];
|
|
814
|
+
return rects.filter((r) => {
|
|
815
|
+
const area = r.width * r.height;
|
|
816
|
+
const bodyArea = phoneBody.width * phoneBody.height;
|
|
817
|
+
if (area > bodyArea * 0.02) return false;
|
|
818
|
+
if (area < 10) return false;
|
|
819
|
+
const isOutsideLeft = r.x + r.width < phoneBody.x + phoneBody.width * 0.05;
|
|
820
|
+
const isOutsideRight = r.x > phoneBody.x + phoneBody.width * 0.95;
|
|
821
|
+
if (!isOutsideLeft && !isOutsideRight) return false;
|
|
822
|
+
if (r.y + r.height < phoneBody.y) return false;
|
|
823
|
+
if (r.y > phoneBody.y + phoneBody.height) return false;
|
|
824
|
+
return true;
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
function classifySideButtons(sideButtons, phoneBody) {
|
|
828
|
+
if (!phoneBody || sideButtons.length === 0) return [];
|
|
829
|
+
const bodyMidX = phoneBody.x + phoneBody.width / 2;
|
|
830
|
+
const classified = [];
|
|
831
|
+
const leftButtons = sideButtons.filter((b) => b.x + b.width / 2 < bodyMidX).sort((a, b) => a.y - b.y);
|
|
832
|
+
const rightButtons = sideButtons.filter((b) => b.x + b.width / 2 >= bodyMidX).sort((a, b) => a.y - b.y);
|
|
833
|
+
if (leftButtons.length >= 2) {
|
|
834
|
+
let bestPairI = -1;
|
|
835
|
+
let bestPairJ = -1;
|
|
836
|
+
let bestScore = Infinity;
|
|
837
|
+
for (let i = 0; i < leftButtons.length; i++) {
|
|
838
|
+
for (let j = i + 1; j < leftButtons.length; j++) {
|
|
839
|
+
const a = leftButtons[i], b = leftButtons[j];
|
|
840
|
+
const areaA = a.width * a.height;
|
|
841
|
+
const areaB = b.width * b.height;
|
|
842
|
+
const sizeDiff = Math.abs(areaA - areaB) / Math.max(areaA, areaB);
|
|
843
|
+
const gap = b.y - (a.y + a.height);
|
|
844
|
+
if (sizeDiff < 0.5 && gap < a.height * 3 && gap > -a.height) {
|
|
845
|
+
const score = sizeDiff * 100 + Math.abs(gap);
|
|
846
|
+
if (score < bestScore) {
|
|
847
|
+
bestScore = score;
|
|
848
|
+
bestPairI = i;
|
|
849
|
+
bestPairJ = j;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (bestPairI >= 0 && bestPairJ >= 0) {
|
|
855
|
+
classified.push({
|
|
856
|
+
type: "volume-up",
|
|
857
|
+
side: "left",
|
|
858
|
+
...leftButtons[bestPairI]
|
|
859
|
+
});
|
|
860
|
+
classified.push({
|
|
861
|
+
type: "volume-down",
|
|
862
|
+
side: "left",
|
|
863
|
+
...leftButtons[bestPairJ]
|
|
864
|
+
});
|
|
865
|
+
for (let i = 0; i < leftButtons.length; i++) {
|
|
866
|
+
if (i !== bestPairI && i !== bestPairJ) {
|
|
867
|
+
classified.push({
|
|
868
|
+
type: "action",
|
|
869
|
+
side: "left",
|
|
870
|
+
...leftButtons[i]
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
} else {
|
|
875
|
+
classified.push({ type: "volume-up", side: "left", ...leftButtons[0] });
|
|
876
|
+
classified.push({ type: "volume-down", side: "left", ...leftButtons[1] });
|
|
877
|
+
for (let i = 2; i < leftButtons.length; i++) {
|
|
878
|
+
classified.push({ type: "action", side: "left", ...leftButtons[i] });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
} else if (leftButtons.length === 1) {
|
|
882
|
+
classified.push({ type: "volume-up", side: "left", ...leftButtons[0] });
|
|
883
|
+
}
|
|
884
|
+
if (rightButtons.length >= 2) {
|
|
885
|
+
const sorted = [...rightButtons].sort((a, b) => a.y - b.y);
|
|
886
|
+
classified.push({ type: "power", side: "right", ...sorted[0] });
|
|
887
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
888
|
+
classified.push({ type: "camera", side: "right", ...sorted[i] });
|
|
889
|
+
}
|
|
890
|
+
} else if (rightButtons.length === 1) {
|
|
891
|
+
classified.push({ type: "power", side: "right", ...rightButtons[0] });
|
|
892
|
+
}
|
|
893
|
+
return classified;
|
|
894
|
+
}
|
|
895
|
+
function findPathCutoutScreen(svgString, svgW, svgH) {
|
|
896
|
+
const svgArea = svgW * svgH;
|
|
897
|
+
const pathRegex = /<path\b([^>]*?)\/>/gi;
|
|
898
|
+
let match;
|
|
899
|
+
const candidates = [];
|
|
900
|
+
while ((match = pathRegex.exec(svgString)) !== null) {
|
|
901
|
+
const attrs = match[1];
|
|
902
|
+
const preceding = svgString.slice(0, match.index);
|
|
903
|
+
if (isInsideNonVisualParent(preceding)) continue;
|
|
904
|
+
const fill = getAttrValue(attrs, "fill");
|
|
905
|
+
if (!fill || isDarkFill(fill)) continue;
|
|
906
|
+
if (fill.startsWith("url(")) continue;
|
|
907
|
+
const d = getAttrValue(attrs, "d");
|
|
908
|
+
if (!d) continue;
|
|
909
|
+
const subpaths = d.split(/[Zz]/).filter((s) => s.trim().length > 0);
|
|
910
|
+
if (subpaths.length < 2) continue;
|
|
911
|
+
const outerBB = subpathBBox(subpaths[0]);
|
|
912
|
+
const innerBB = subpathBBox(subpaths[1]);
|
|
913
|
+
if (!outerBB || !innerBB) continue;
|
|
914
|
+
const outerArea = outerBB.w * outerBB.h;
|
|
915
|
+
if (outerArea < svgArea * 0.3) continue;
|
|
916
|
+
const innerArea = innerBB.w * innerBB.h;
|
|
917
|
+
if (innerArea < outerArea * 0.5 || innerArea >= outerArea * 0.98) continue;
|
|
918
|
+
if (innerBB.h < innerBB.w * 1.3) continue;
|
|
919
|
+
if (innerBB.x < outerBB.x || innerBB.y < outerBB.y) continue;
|
|
920
|
+
if (innerBB.x + innerBB.w > outerBB.x + outerBB.w + 5) continue;
|
|
921
|
+
if (innerBB.y + innerBB.h > outerBB.y + outerBB.h + 5) continue;
|
|
922
|
+
const estimatedRx = Math.round(Math.min(innerBB.w, innerBB.h) * 0.04);
|
|
923
|
+
candidates.push({ inner: innerBB, rx: Math.max(estimatedRx, 20), fill });
|
|
924
|
+
}
|
|
925
|
+
if (candidates.length === 0) return null;
|
|
926
|
+
candidates.sort((a, b) => b.inner.w * b.inner.h - a.inner.w * a.inner.h);
|
|
927
|
+
const best = candidates[0];
|
|
928
|
+
return {
|
|
929
|
+
x: best.inner.x,
|
|
930
|
+
y: best.inner.y,
|
|
931
|
+
width: best.inner.w,
|
|
932
|
+
height: best.inner.h,
|
|
933
|
+
rx: best.rx,
|
|
934
|
+
fill: "path-cutout",
|
|
935
|
+
id: null
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function subpathBBox(subpath) {
|
|
939
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
940
|
+
let curX = 0, curY = 0;
|
|
941
|
+
function addPoint(x, y) {
|
|
942
|
+
if (!isFinite(x) || !isFinite(y)) return;
|
|
943
|
+
minX = Math.min(minX, x);
|
|
944
|
+
minY = Math.min(minY, y);
|
|
945
|
+
maxX = Math.max(maxX, x);
|
|
946
|
+
maxY = Math.max(maxY, y);
|
|
947
|
+
curX = x;
|
|
948
|
+
curY = y;
|
|
949
|
+
}
|
|
950
|
+
const tokens = subpath.match(/[MmLlHhVvCcSsQqTtAaZz]|-?\d+\.?\d*(?:e[+-]?\d+)?/gi);
|
|
951
|
+
if (!tokens || tokens.length < 3) return null;
|
|
952
|
+
const toks = tokens;
|
|
953
|
+
let i = 0;
|
|
954
|
+
function nextNum() {
|
|
955
|
+
return i < toks.length ? parseFloat(toks[i++]) : 0;
|
|
956
|
+
}
|
|
957
|
+
while (i < toks.length) {
|
|
958
|
+
const cmd = toks[i];
|
|
959
|
+
if (/^[A-Za-z]$/.test(cmd)) {
|
|
960
|
+
i++;
|
|
961
|
+
switch (cmd) {
|
|
962
|
+
case "M":
|
|
963
|
+
case "L":
|
|
964
|
+
addPoint(nextNum(), nextNum());
|
|
965
|
+
break;
|
|
966
|
+
case "m":
|
|
967
|
+
case "l":
|
|
968
|
+
addPoint(curX + nextNum(), curY + nextNum());
|
|
969
|
+
break;
|
|
970
|
+
case "H":
|
|
971
|
+
addPoint(nextNum(), curY);
|
|
972
|
+
break;
|
|
973
|
+
case "h":
|
|
974
|
+
addPoint(curX + nextNum(), curY);
|
|
975
|
+
break;
|
|
976
|
+
case "V":
|
|
977
|
+
addPoint(curX, nextNum());
|
|
978
|
+
break;
|
|
979
|
+
case "v":
|
|
980
|
+
addPoint(curX, curY + nextNum());
|
|
981
|
+
break;
|
|
982
|
+
case "C": {
|
|
983
|
+
nextNum();
|
|
984
|
+
nextNum();
|
|
985
|
+
nextNum();
|
|
986
|
+
nextNum();
|
|
987
|
+
addPoint(nextNum(), nextNum());
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
case "c": {
|
|
991
|
+
nextNum();
|
|
992
|
+
nextNum();
|
|
993
|
+
nextNum();
|
|
994
|
+
nextNum();
|
|
995
|
+
addPoint(curX + nextNum(), curY + nextNum());
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
case "S":
|
|
999
|
+
case "Q": {
|
|
1000
|
+
nextNum();
|
|
1001
|
+
nextNum();
|
|
1002
|
+
addPoint(nextNum(), nextNum());
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
case "s":
|
|
1006
|
+
case "q": {
|
|
1007
|
+
nextNum();
|
|
1008
|
+
nextNum();
|
|
1009
|
+
addPoint(curX + nextNum(), curY + nextNum());
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
case "T":
|
|
1013
|
+
addPoint(nextNum(), nextNum());
|
|
1014
|
+
break;
|
|
1015
|
+
case "t":
|
|
1016
|
+
addPoint(curX + nextNum(), curY + nextNum());
|
|
1017
|
+
break;
|
|
1018
|
+
case "A": {
|
|
1019
|
+
nextNum();
|
|
1020
|
+
nextNum();
|
|
1021
|
+
nextNum();
|
|
1022
|
+
nextNum();
|
|
1023
|
+
nextNum();
|
|
1024
|
+
addPoint(nextNum(), nextNum());
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
case "a": {
|
|
1028
|
+
nextNum();
|
|
1029
|
+
nextNum();
|
|
1030
|
+
nextNum();
|
|
1031
|
+
nextNum();
|
|
1032
|
+
nextNum();
|
|
1033
|
+
addPoint(curX + nextNum(), curY + nextNum());
|
|
1034
|
+
break;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
} else {
|
|
1038
|
+
i++;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (!isFinite(minX) || maxX - minX < 10 || maxY - minY < 10) return null;
|
|
1042
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
1043
|
+
}
|
|
1044
|
+
function isLightFill(fill) {
|
|
1045
|
+
if (!fill) return false;
|
|
1046
|
+
const f = fill.toLowerCase().trim();
|
|
1047
|
+
if (f === "white" || f === "#fff" || f === "#ffffff") return true;
|
|
1048
|
+
if (f === "rgb(255,255,255)" || f === "rgb(255, 255, 255)") return true;
|
|
1049
|
+
const hex6 = f.match(/^#([0-9a-f]{6})$/);
|
|
1050
|
+
if (hex6) {
|
|
1051
|
+
const r = parseInt(hex6[1].slice(0, 2), 16);
|
|
1052
|
+
const g = parseInt(hex6[1].slice(2, 4), 16);
|
|
1053
|
+
const b = parseInt(hex6[1].slice(4, 6), 16);
|
|
1054
|
+
return r > 200 && g > 200 && b > 200;
|
|
1055
|
+
}
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
function isDarkFill(fill) {
|
|
1059
|
+
if (!fill) return false;
|
|
1060
|
+
const f = fill.toLowerCase().trim();
|
|
1061
|
+
if (f === "black" || f === "#000" || f === "#000000") return true;
|
|
1062
|
+
if (f.startsWith("rgb(0") || f === "rgb(0,0,0)" || f === "rgb(0, 0, 0)") return true;
|
|
1063
|
+
const hex6 = f.match(/^#([0-9a-f]{6})$/);
|
|
1064
|
+
if (hex6) {
|
|
1065
|
+
const r = parseInt(hex6[1].slice(0, 2), 16);
|
|
1066
|
+
const g = parseInt(hex6[1].slice(2, 4), 16);
|
|
1067
|
+
const b = parseInt(hex6[1].slice(4, 6), 16);
|
|
1068
|
+
return r < 50 && g < 50 && b < 50;
|
|
1069
|
+
}
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
function isInsideRect(inner, outer, tolerance) {
|
|
1073
|
+
const tolX = outer.width * tolerance;
|
|
1074
|
+
const tolY = outer.height * tolerance;
|
|
1075
|
+
return inner.x >= outer.x - tolX && inner.y >= outer.y - tolY && inner.x + inner.width <= outer.x + outer.width + tolX && inner.y + inner.height <= outer.y + outer.height + tolY;
|
|
1076
|
+
}
|
|
1077
|
+
function extractViewBox(svgString) {
|
|
1078
|
+
const vbMatch = svgString.match(/<svg\b[^>]*viewBox\s*=\s*["']([^"']*)["']/i);
|
|
1079
|
+
if (vbMatch) {
|
|
1080
|
+
const parts = vbMatch[1].trim().split(/[\s,]+/).map(Number);
|
|
1081
|
+
if (parts.length >= 4 && parts.every((n) => isFinite(n))) {
|
|
1082
|
+
return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
const wMatch = svgString.match(/<svg\b[^>]*\bwidth\s*=\s*["']?(\d+\.?\d*)["']?/i);
|
|
1086
|
+
const hMatch = svgString.match(/<svg\b[^>]*\bheight\s*=\s*["']?(\d+\.?\d*)["']?/i);
|
|
1087
|
+
if (wMatch && hMatch) {
|
|
1088
|
+
return { x: 0, y: 0, w: parseFloat(wMatch[1]), h: parseFloat(hMatch[1]) };
|
|
1089
|
+
}
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
function extractAllRects(svgString) {
|
|
1093
|
+
const rects = [];
|
|
1094
|
+
const regex = /<rect\b([^>]*?)(?:\/>|>[^<]*<\/rect>)/gi;
|
|
1095
|
+
let match;
|
|
1096
|
+
while ((match = regex.exec(svgString)) !== null) {
|
|
1097
|
+
const attrs = match[1];
|
|
1098
|
+
const preceding = svgString.slice(0, match.index);
|
|
1099
|
+
if (isInsideNonVisualParent(preceding)) continue;
|
|
1100
|
+
const x = getNumAttr(attrs, "x");
|
|
1101
|
+
const y = getNumAttr(attrs, "y");
|
|
1102
|
+
const width = getNumAttr(attrs, "width");
|
|
1103
|
+
const height = getNumAttr(attrs, "height");
|
|
1104
|
+
if (width <= 0 || height <= 0) continue;
|
|
1105
|
+
const transformedPos = applyTransformToRect(attrs, x, y, width, height);
|
|
1106
|
+
const rx = Math.max(getNumAttr(attrs, "rx"), getNumAttr(attrs, "ry"));
|
|
1107
|
+
const fill = getAttrValue(attrs, "fill");
|
|
1108
|
+
const id = getAttrValue(attrs, "id");
|
|
1109
|
+
rects.push({
|
|
1110
|
+
x: transformedPos.x,
|
|
1111
|
+
y: transformedPos.y,
|
|
1112
|
+
width: transformedPos.width,
|
|
1113
|
+
height: transformedPos.height,
|
|
1114
|
+
rx,
|
|
1115
|
+
fill,
|
|
1116
|
+
id
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
return rects;
|
|
1120
|
+
}
|
|
1121
|
+
function isInsideNonVisualParent(preceding) {
|
|
1122
|
+
const nonVisualTags = ["defs", "clipPath", "mask", "filter"];
|
|
1123
|
+
for (const tag of nonVisualTags) {
|
|
1124
|
+
const openCount = countOccurrences(preceding, `<${tag}`);
|
|
1125
|
+
const closeCount = countOccurrences(preceding, `</${tag}`);
|
|
1126
|
+
if (openCount > closeCount) return true;
|
|
1127
|
+
}
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
function countOccurrences(str, substr) {
|
|
1131
|
+
let count = 0;
|
|
1132
|
+
let pos = 0;
|
|
1133
|
+
while ((pos = str.indexOf(substr, pos)) !== -1) {
|
|
1134
|
+
count++;
|
|
1135
|
+
pos += substr.length;
|
|
1136
|
+
}
|
|
1137
|
+
return count;
|
|
1138
|
+
}
|
|
1139
|
+
function applyTransformToRect(attrs, x, y, width, height) {
|
|
1140
|
+
const transformAttr = getAttrValue(attrs, "transform");
|
|
1141
|
+
if (!transformAttr) return { x, y, width, height };
|
|
1142
|
+
const matrixMatch = transformAttr.match(
|
|
1143
|
+
/matrix\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\)/
|
|
1144
|
+
);
|
|
1145
|
+
if (matrixMatch) {
|
|
1146
|
+
const a = parseFloat(matrixMatch[1]);
|
|
1147
|
+
const d = parseFloat(matrixMatch[4]);
|
|
1148
|
+
const tx = parseFloat(matrixMatch[5]);
|
|
1149
|
+
const ty = parseFloat(matrixMatch[6]);
|
|
1150
|
+
if (a === -1 && d === 1) {
|
|
1151
|
+
return { x: tx - x - width, y: y + ty, width, height };
|
|
1152
|
+
}
|
|
1153
|
+
if (a === 1 && d === -1) {
|
|
1154
|
+
return { x: x + tx, y: ty - y - height, width, height };
|
|
1155
|
+
}
|
|
1156
|
+
if (a === 1 && d === 1) {
|
|
1157
|
+
return { x: x + tx, y: y + ty, width, height };
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return { x, y, width, height };
|
|
1161
|
+
}
|
|
1162
|
+
function detectByNames(svgString) {
|
|
1163
|
+
const detected = [];
|
|
1164
|
+
const usedTypes = /* @__PURE__ */ new Set();
|
|
1165
|
+
const elements = extractSVGElements(svgString);
|
|
1166
|
+
for (const el of elements) {
|
|
1167
|
+
const identifiers = [
|
|
1168
|
+
el.id,
|
|
1169
|
+
el.className,
|
|
1170
|
+
el.dataName,
|
|
1171
|
+
el.dataZone,
|
|
1172
|
+
el.ariaLabel
|
|
1173
|
+
].filter(Boolean).map((s) => s.toLowerCase());
|
|
1174
|
+
for (const zoneConfig of ZONE_KEYWORDS) {
|
|
1175
|
+
if (usedTypes.has(zoneConfig.type)) continue;
|
|
1176
|
+
for (const keyword of zoneConfig.keywords) {
|
|
1177
|
+
const matched = identifiers.some(
|
|
1178
|
+
(ident) => ident === keyword || ident.includes(keyword) || ident.replace(/[_\s]/g, "-").includes(keyword)
|
|
1179
|
+
);
|
|
1180
|
+
if (matched) {
|
|
1181
|
+
const confidence = zoneConfig.highConfidence.some(
|
|
1182
|
+
(hk) => identifiers.some((ident) => ident === hk || ident.includes(hk))
|
|
1183
|
+
) ? "high" : "medium";
|
|
1184
|
+
detected.push({
|
|
1185
|
+
id: `auto-${zoneConfig.type}-${Date.now()}`,
|
|
1186
|
+
type: zoneConfig.type,
|
|
1187
|
+
sourceLabel: el.id || el.dataName || el.className || el.ariaLabel || "unknown",
|
|
1188
|
+
matchedKeyword: keyword,
|
|
1189
|
+
confidence,
|
|
1190
|
+
x: el.x,
|
|
1191
|
+
y: el.y,
|
|
1192
|
+
width: el.width,
|
|
1193
|
+
height: el.height
|
|
1194
|
+
});
|
|
1195
|
+
usedTypes.add(zoneConfig.type);
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return detected;
|
|
1202
|
+
}
|
|
1203
|
+
function extractSVGElements(svgString) {
|
|
1204
|
+
const elements = [];
|
|
1205
|
+
const tagRegex = /<(rect|g|path|circle|ellipse|use|foreignObject|image)\b([^>]*?)(?:\/>|>)/gi;
|
|
1206
|
+
let match;
|
|
1207
|
+
while ((match = tagRegex.exec(svgString)) !== null) {
|
|
1208
|
+
const tag = match[1].toLowerCase();
|
|
1209
|
+
const attrs = match[2];
|
|
1210
|
+
const id = getAttrValue(attrs, "id");
|
|
1211
|
+
const className = getAttrValue(attrs, "class");
|
|
1212
|
+
const dataName = getAttrValue(attrs, "data-name");
|
|
1213
|
+
const dataZone = getAttrValue(attrs, "data-zone");
|
|
1214
|
+
const ariaLabel = getAttrValue(attrs, "aria-label");
|
|
1215
|
+
if (!id && !className && !dataName && !dataZone && !ariaLabel) continue;
|
|
1216
|
+
const bbox = extractBBox(tag, attrs);
|
|
1217
|
+
elements.push({ tag, id, className, dataName, dataZone, ariaLabel, ...bbox });
|
|
1218
|
+
}
|
|
1219
|
+
return elements;
|
|
1220
|
+
}
|
|
1221
|
+
function getAttrValue(attrs, name) {
|
|
1222
|
+
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i");
|
|
1223
|
+
const m = attrs.match(re);
|
|
1224
|
+
return m ? m[1] : null;
|
|
1225
|
+
}
|
|
1226
|
+
function getNumAttr(attrs, name) {
|
|
1227
|
+
const re = new RegExp(`\\b${name}\\s*=\\s*["']([\\d.]+)["']`, "i");
|
|
1228
|
+
const m = attrs.match(re);
|
|
1229
|
+
return m ? parseFloat(m[1]) : 0;
|
|
1230
|
+
}
|
|
1231
|
+
function extractBBox(tag, attrs) {
|
|
1232
|
+
switch (tag) {
|
|
1233
|
+
case "rect":
|
|
1234
|
+
case "foreignobject":
|
|
1235
|
+
case "image":
|
|
1236
|
+
return {
|
|
1237
|
+
x: getNumAttr(attrs, "x"),
|
|
1238
|
+
y: getNumAttr(attrs, "y"),
|
|
1239
|
+
width: getNumAttr(attrs, "width"),
|
|
1240
|
+
height: getNumAttr(attrs, "height")
|
|
1241
|
+
};
|
|
1242
|
+
case "circle": {
|
|
1243
|
+
const cx = getNumAttr(attrs, "cx");
|
|
1244
|
+
const cy = getNumAttr(attrs, "cy");
|
|
1245
|
+
const r = getNumAttr(attrs, "r");
|
|
1246
|
+
return { x: cx - r, y: cy - r, width: r * 2, height: r * 2 };
|
|
1247
|
+
}
|
|
1248
|
+
case "ellipse": {
|
|
1249
|
+
const cx = getNumAttr(attrs, "cx");
|
|
1250
|
+
const cy = getNumAttr(attrs, "cy");
|
|
1251
|
+
const rx = getNumAttr(attrs, "rx");
|
|
1252
|
+
const ry = getNumAttr(attrs, "ry");
|
|
1253
|
+
return { x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 };
|
|
1254
|
+
}
|
|
1255
|
+
case "path":
|
|
1256
|
+
return extractPathBBox(attrs);
|
|
1257
|
+
case "g":
|
|
1258
|
+
case "use":
|
|
1259
|
+
return {
|
|
1260
|
+
x: getNumAttr(attrs, "x"),
|
|
1261
|
+
y: getNumAttr(attrs, "y"),
|
|
1262
|
+
width: getNumAttr(attrs, "width"),
|
|
1263
|
+
height: getNumAttr(attrs, "height")
|
|
1264
|
+
};
|
|
1265
|
+
default:
|
|
1266
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
function extractPathBBox(attrs) {
|
|
1270
|
+
const dAttr = getAttrValue(attrs, "d");
|
|
1271
|
+
if (!dAttr) return { x: 0, y: 0, width: 0, height: 0 };
|
|
1272
|
+
const nums = dAttr.match(/-?\d+\.?\d*/g);
|
|
1273
|
+
if (!nums || nums.length < 4) return { x: 0, y: 0, width: 0, height: 0 };
|
|
1274
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1275
|
+
for (let i = 0; i < nums.length - 1; i += 2) {
|
|
1276
|
+
const x = parseFloat(nums[i]);
|
|
1277
|
+
const y = parseFloat(nums[i + 1]);
|
|
1278
|
+
if (isFinite(x) && isFinite(y)) {
|
|
1279
|
+
minX = Math.min(minX, x);
|
|
1280
|
+
minY = Math.min(minY, y);
|
|
1281
|
+
maxX = Math.max(maxX, x);
|
|
1282
|
+
maxY = Math.max(maxY, y);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if (!isFinite(minX)) return { x: 0, y: 0, width: 0, height: 0 };
|
|
1286
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
1287
|
+
}
|
|
1288
|
+
function listSVGLayerNames(svgString) {
|
|
1289
|
+
const elements = extractSVGElements(svgString);
|
|
1290
|
+
return elements.map((el) => ({
|
|
1291
|
+
tag: el.tag,
|
|
1292
|
+
name: el.id || el.dataName || el.className || el.ariaLabel || `<${el.tag}>`
|
|
1293
|
+
}));
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
export { BUILTIN_DEVICES, DEVICE_LAYOUTS, DeviceRegistry, analyzeSVGGeometry, autoDetectZones, buildAIPromptConstraints, buildCSSVariables, classifySideButtons, deriveContentZone, extractZones, getAllDeviceIds, getDeviceContract, getDeviceMetadata, getDeviceRegistry, listSVGLayerNames, normalizeSVGToLogicalPoints, parseSVGNativeDimensions, parseSVGToContract, resetDeviceRegistry, scopeSVGIds, validateNormalizedSVG };
|
|
1297
|
+
//# sourceMappingURL=index.js.map
|
|
1298
|
+
//# sourceMappingURL=index.js.map
|