jehian commited on
Commit
0ca9683
·
verified ·
1 Parent(s): d75f1d5

Upload 15 files

Browse files
package-lock.json CHANGED
@@ -1,11 +1,11 @@
1
  {
2
- "name": "tempelhtml",
3
  "version": "0.1.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
- "name": "tempelhtml",
9
  "version": "0.1.0",
10
  "dependencies": {
11
  "commander": "^12.0.0",
 
1
  {
2
+ "name": "morphus",
3
  "version": "0.1.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
+ "name": "morphus",
9
  "version": "0.1.0",
10
  "dependencies": {
11
  "commander": "^12.0.0",
package.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
- "name": "tempelhtml",
3
  "version": "0.1.0",
4
- "description": "HTML to Figma converter with a deterministic local pipeline",
5
  "type": "module",
6
  "main": "scripts/convert.js",
7
  "scripts": {
 
1
  {
2
+ "name": "morphus",
3
  "version": "0.1.0",
4
+ "description": "Morphus converts HTML into editable Figma designs with a deterministic local pipeline",
5
  "type": "module",
6
  "main": "scripts/convert.js",
7
  "scripts": {
scripts/convert.js CHANGED
@@ -1,6 +1,6 @@
1
  #!/usr/bin/env node
2
  /**
3
- * tempelhtml CLI
4
  * Usage: node scripts/convert.js --input ./examples/page.html --output ./out/page.json
5
  */
6
 
 
1
  #!/usr/bin/env node
2
  /**
3
+ * Morphus CLI
4
  * Usage: node scripts/convert.js --input ./examples/page.html --output ./out/page.json
5
  */
6
 
scripts/server.js CHANGED
@@ -1,6 +1,6 @@
1
  #!/usr/bin/env node
2
  /**
3
- * Local tempelhtml bridge for the Figma plugin.
4
  * The plugin UI sends HTML here, and this server returns the converted JSON.
5
  */
6
 
@@ -8,7 +8,7 @@ import http from 'node:http';
8
  import { randomUUID } from 'node:crypto';
9
  import { convertHtmlString } from '../src/pipeline/convert.js';
10
 
11
- const PORT = Number.parseInt(process.env.PORT ?? process.env.TEMPELHTML_PORT ?? '3210', 10);
12
  const HOST = process.env.HOST ?? '0.0.0.0';
13
  const jobs = new Map();
14
 
@@ -103,7 +103,7 @@ const server = http.createServer(async (req, res) => {
103
 
104
  server.listen(PORT, HOST, () => {
105
  const displayHost = HOST === '0.0.0.0' ? 'localhost' : HOST;
106
- console.log(`tempelhtml server listening on http://${displayHost}:${PORT}`);
107
  });
108
 
109
  async function runJob(jobId, body) {
 
1
  #!/usr/bin/env node
2
  /**
3
+ * Local Morphus bridge for the Figma plugin.
4
  * The plugin UI sends HTML here, and this server returns the converted JSON.
5
  */
6
 
 
8
  import { randomUUID } from 'node:crypto';
9
  import { convertHtmlString } from '../src/pipeline/convert.js';
10
 
11
+ const PORT = Number.parseInt(process.env.PORT ?? process.env.MORPHUS_PORT ?? '3210', 10);
12
  const HOST = process.env.HOST ?? '0.0.0.0';
13
  const jobs = new Map();
14
 
 
103
 
104
  server.listen(PORT, HOST, () => {
105
  const displayHost = HOST === '0.0.0.0' ? 'localhost' : HOST;
106
+ console.log(`Morphus server listening on http://${displayHost}:${PORT}`);
107
  });
108
 
109
  async function runJob(jobId, body) {
src/core/extractor.js CHANGED
@@ -1,1013 +1,1353 @@
1
- /**
2
- * src/core/extractor.js
3
- * Renders HTML in headless Playwright, extracts computed styles
4
- * and bounding rects for every DOM element.
5
- */
6
-
7
- import { chromium } from 'playwright-core';
8
- import { existsSync, statSync } from 'fs';
9
- import { dirname, resolve } from 'path';
10
- import { pathToFileURL } from 'url';
11
-
12
- /**
13
- * @param {string} filePath - absolute or relative path to HTML file
14
- * @param {{ width: number, height: number }} viewport
15
- * @returns {{ domTree }}
16
- */
17
- export async function extractFromFile(filePath, { width = 1440, height = 900 } = {}) {
18
- const absPath = resolve(filePath);
19
- const browser = await chromium.launch();
20
- const page = await browser.newPage({ viewport: { width, height } });
21
-
22
- await page.goto(pathToFileURL(absPath).href);
23
- const result = await extractFromPage(page);
24
- await browser.close();
25
- return result;
26
- }
27
-
28
- /**
29
- * @param {string} html
30
- * @param {{ width?: number, height?: number, baseUrl?: string | null }} options
31
- * @returns {{ domTree }}
32
- */
33
- export async function extractFromHtml(html, { width = 1440, height = 900, baseUrl = null } = {}) {
34
- const browser = await chromium.launch();
35
- const page = await browser.newPage({ viewport: { width, height } });
36
- const htmlWithBase = injectBaseHref(html, normalizeBaseUrl(baseUrl));
37
-
38
- await page.setContent(htmlWithBase, { waitUntil: 'load' });
39
- const result = await extractFromPage(page);
40
- await browser.close();
41
- return result;
42
- }
43
-
44
- async function extractFromPage(page) {
45
- await stabilizePage(page);
46
-
47
- // Walk the full DOM and capture computed styles + rects
48
- const domTree = await page.evaluate(walkDOMInBrowser);
49
- return { domTree };
50
- }
51
-
52
- async function stabilizePage(page) {
53
- await page.evaluate(() => {
54
- document.querySelectorAll('.reveal').forEach(el => el.classList.add('visible'));
55
-
56
- const animated = Array.from(document.querySelectorAll('*')).filter((el) => {
57
- const cs = window.getComputedStyle(el);
58
- return cs.animationName !== 'none' || cs.transitionDuration !== '0s';
59
- });
60
- animated.forEach((el) => el.setAttribute('data-tempelhtml-animated', '1'));
61
-
62
- const style = document.createElement('style');
63
- style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
64
- document.head.appendChild(style);
65
-
66
- document.querySelectorAll('[data-tempelhtml-animated="1"]').forEach((el) => {
 
 
 
67
  const cs = window.getComputedStyle(el);
68
- if (cs.opacity === '0') {
69
  el.style.opacity = '1';
70
- }
71
- if (cs.transform !== 'none') {
72
- el.style.transform = 'none';
73
  }
74
  });
75
- });
76
-
77
- await page.waitForLoadState('networkidle');
78
- }
79
-
80
- function normalizeBaseUrl(baseUrl) {
81
- if (!baseUrl) return null;
82
- if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(baseUrl)) return baseUrl;
83
-
84
- const absPath = resolve(baseUrl);
85
- const targetPath = existsSync(absPath) && !statSync(absPath).isDirectory()
86
- ? dirname(absPath)
87
- : absPath;
88
-
89
- let href = pathToFileURL(targetPath).href;
90
- if (!href.endsWith('/')) {
91
- href += '/';
92
- }
93
- return href;
94
- }
95
-
96
- function injectBaseHref(html, baseHref) {
97
- if (!baseHref || /<base\s/i.test(html)) {
98
- return html;
99
- }
100
-
101
- const baseTag = `<base href="${escapeHtmlAttribute(baseHref)}">`;
102
- if (/<head[^>]*>/i.test(html)) {
103
- return html.replace(/<head([^>]*)>/i, `<head$1>\n${baseTag}`);
104
- }
105
-
106
- if (/<html[^>]*>/i.test(html)) {
107
- return html.replace(/<html([^>]*)>/i, `<html$1><head>${baseTag}</head>`);
108
- }
109
-
110
- return `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
111
- }
112
-
113
- function escapeHtmlAttribute(value) {
114
- return String(value)
115
- .replace(/&/g, '&amp;')
116
- .replace(/"/g, '&quot;');
117
- }
118
-
119
- /**
120
- * This function is serialized and run inside the browser context.
121
- * It must be self-contained (no imports).
122
- */
123
- function walkDOMInBrowser() {
124
- const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'HEAD', 'NOSCRIPT']);
125
- const TEXT_TAGS = new Set(['p', 'span', 'a', 'label', 'em', 'strong', 'b', 'i', 'small', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
126
- const INLINE_TAGS = new Set(['span', 'a', 'label', 'em', 'strong', 'b', 'i', 'small', 'mark', 'sup', 'sub', 'u', 's', 'code', 'br', 'wbr']);
127
- const TEXT_INPUT_TYPES = new Set([
128
- '',
129
- 'date',
130
- 'datetime-local',
131
- 'email',
132
- 'month',
133
- 'number',
134
- 'password',
135
- 'search',
136
- 'tel',
137
- 'text',
138
- 'time',
139
- 'url',
140
- 'week',
141
- ]);
142
-
143
- function getNode(el, depth = 0) {
144
- if (SKIP_TAGS.has(el.tagName)) return null;
145
-
146
- const rect = el.getBoundingClientRect();
147
- const cs = window.getComputedStyle(el);
148
- const csBefore = window.getComputedStyle(el, '::before');
149
- const csAfter = window.getComputedStyle(el, '::after');
150
- const tag = el.tagName.toLowerCase();
151
- const isSvg = tag === 'svg';
152
 
153
- // Skip invisible/zero-size elements
154
- if (rect.width === 0 && rect.height === 0 && cs.position === 'static') return null;
155
 
156
- const rawText = normalizeTextContent(el.innerText || el.textContent || '');
157
- const hasVisualBox =
158
- !isTransparentColor(cs.backgroundColor) ||
159
- cs.backgroundImage !== 'none' ||
160
- cs.borderStyle !== 'none' ||
161
- parseFloat(cs.borderTopWidth) > 0 ||
162
- parseFloat(cs.borderRightWidth) > 0 ||
163
- parseFloat(cs.borderBottomWidth) > 0 ||
164
- parseFloat(cs.borderLeftWidth) > 0 ||
165
- parseFloat(cs.paddingTop) > 0 ||
166
- parseFloat(cs.paddingRight) > 0 ||
167
- parseFloat(cs.paddingBottom) > 0 ||
168
- parseFloat(cs.paddingLeft) > 0 ||
169
- cs.boxShadow !== 'none';
170
- const hasOnlyInlineTextChildren = Boolean(rawText) && Array.from(el.children).length > 0 && Array.from(el.children).every((child) => isInlineTextChild(child));
171
- const isTextContainer = Boolean(rawText)
172
- && !hasVisualBox
173
- && !hasRenderablePseudo(csBefore)
174
- && !hasRenderablePseudo(csAfter)
175
- && canCollapseToTextContainer(el, tag, cs, hasOnlyInlineTextChildren);
176
-
177
- const textData = rawText ? extractTextData(el) : null;
178
- const beforeData = extractPseudoElementData(el, tag, cs, csBefore, 'before');
179
- const afterData = extractPseudoElementData(el, tag, cs, csAfter, 'after');
180
- const formControl = extractFormControlData(el, tag, cs);
181
- const svgMarkup = isSvg ? serializeSvgElement(el, rect) : null;
182
-
183
- const children = isSvg || isTextContainer
184
- ? []
185
- : Array.from(el.childNodes)
186
- .map((child) => getChildNode(child, el, cs, depth + 1))
187
- .filter(Boolean);
188
-
189
- return {
190
- tag,
191
- id: el.id || null,
192
- classList: Array.from(el.classList),
193
- text: rawText || null,
194
- textRuns: textData?.runs || [],
195
- isTextContainer,
196
- rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
197
- computed: extractRelevantStyles(cs),
198
- ...(formControl ? { formControl } : {}),
199
- ...(svgMarkup ? { svgMarkup } : {}),
200
- pseudo: {
201
- before: beforeData,
202
- after: afterData,
203
- },
204
- children,
205
- };
206
- }
207
-
208
- function extractFormControlData(el, tag, computedStyles) {
209
- if (tag !== 'input' && tag !== 'textarea') {
210
- return null;
211
- }
212
-
213
- const type = tag === 'input'
214
- ? String(el.getAttribute('type') || 'text').trim().toLowerCase()
215
- : 'textarea';
216
-
217
- if (tag === 'input' && !TEXT_INPUT_TYPES.has(type)) {
218
- return null;
219
- }
220
-
221
- const placeholder = normalizeFormControlText(el.getAttribute('placeholder') || '', tag === 'textarea');
222
- const value = type === 'password'
223
- ? ''
224
- : normalizeFormControlText(el.value || '', tag === 'textarea');
225
-
226
- if (!placeholder && !value) {
227
- return null;
228
  }
229
 
230
- const placeholderComputed = placeholder
231
- ? extractPlaceholderStyles(el, computedStyles)
232
- : null;
233
-
234
- return {
235
- type,
236
- value,
237
- placeholder,
238
- ...(placeholderComputed ? { placeholderComputed } : {}),
239
- };
240
- }
241
-
242
- function extractPlaceholderStyles(el, fallbackStyles) {
243
- try {
244
- const placeholderStyles = window.getComputedStyle(el, '::placeholder');
245
- if (placeholderStyles) {
246
- return extractRelevantStyles(placeholderStyles);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  }
248
- } catch (err) {}
249
-
250
- return extractRelevantStyles(fallbackStyles);
251
- }
252
-
253
- function extractRelevantStyles(cs) {
254
- return {
255
- display: cs.display,
256
- position: cs.position,
257
- zIndex: cs.zIndex,
258
- // Layout
259
- flexDirection: cs.flexDirection,
260
- justifyContent: cs.justifyContent,
261
- alignItems: cs.alignItems,
262
- flexWrap: cs.flexWrap,
263
- gap: cs.gap,
264
- columnGap: cs.columnGap,
265
- rowGap: cs.rowGap,
266
- gridTemplateColumns: cs.gridTemplateColumns,
267
- gridTemplateRows: cs.gridTemplateRows,
268
- gridRow: cs.gridRow,
269
- gridColumn: cs.gridColumn,
270
- // Sizing
271
- width: cs.width,
272
- height: cs.height,
273
- minWidth: cs.minWidth,
274
- maxWidth: cs.maxWidth,
275
- minHeight: cs.minHeight,
276
- // Spacing
277
- paddingTop: cs.paddingTop,
278
- paddingRight: cs.paddingRight,
279
- paddingBottom: cs.paddingBottom,
280
- paddingLeft: cs.paddingLeft,
281
- marginTop: cs.marginTop,
282
- marginRight: cs.marginRight,
283
- marginBottom: cs.marginBottom,
284
- marginLeft: cs.marginLeft,
285
- // Visual
286
- backgroundColor: cs.backgroundColor,
287
- backgroundImage: cs.backgroundImage,
288
- backgroundSize: cs.backgroundSize,
289
- backgroundPosition: cs.backgroundPosition,
290
- color: cs.color,
291
- opacity: cs.opacity,
292
- borderRadius: cs.borderRadius,
293
- borderTopLeftRadius: cs.borderTopLeftRadius,
294
- borderTopRightRadius: cs.borderTopRightRadius,
295
- borderBottomRightRadius: cs.borderBottomRightRadius,
296
- borderBottomLeftRadius: cs.borderBottomLeftRadius,
297
- border: cs.border,
298
- borderWidth: cs.borderWidth,
299
- borderColor: cs.borderColor,
300
- borderStyle: cs.borderStyle,
301
- borderTopWidth: cs.borderTopWidth,
302
- borderRightWidth: cs.borderRightWidth,
303
- borderBottomWidth: cs.borderBottomWidth,
304
- borderLeftWidth: cs.borderLeftWidth,
305
- borderTopColor: cs.borderTopColor,
306
- borderRightColor: cs.borderRightColor,
307
- borderBottomColor: cs.borderBottomColor,
308
- borderLeftColor: cs.borderLeftColor,
309
- borderTopStyle: cs.borderTopStyle,
310
- borderRightStyle: cs.borderRightStyle,
311
- borderBottomStyle: cs.borderBottomStyle,
312
- borderLeftStyle: cs.borderLeftStyle,
313
- boxShadow: cs.boxShadow,
314
- overflow: cs.overflow,
315
- overflowX: cs.overflowX,
316
- overflowY: cs.overflowY,
317
- clipPath: cs.clipPath,
318
- mixBlendMode: cs.mixBlendMode,
319
- transform: cs.transform,
320
- // Typography
321
- fontFamily: cs.fontFamily,
322
- fontSize: cs.fontSize,
323
- fontWeight: cs.fontWeight,
324
- fontStyle: cs.fontStyle,
325
- lineHeight: cs.lineHeight,
326
- letterSpacing: cs.letterSpacing,
327
- textAlign: cs.textAlign,
328
- textTransform: cs.textTransform,
329
- whiteSpace: cs.whiteSpace,
330
- textDecoration: cs.textDecoration,
331
- webkitTextStrokeWidth: cs.webkitTextStrokeWidth,
332
- webkitTextStrokeColor: cs.webkitTextStrokeColor,
333
- // Positioning
334
- top: cs.top,
335
- right: cs.right,
336
- bottom: cs.bottom,
337
- left: cs.left,
338
- inset: cs.inset,
339
- // Content (for pseudo-elements)
340
- content: cs.content,
341
- };
342
- }
343
-
344
- function extractTextData(el) {
345
- const runs = [];
346
- const pieces = [];
347
- let lineIndex = 0;
348
-
349
- function pushText(text, styleEl) {
350
- const normalized = normalizeTextFragment(text);
351
- if (!normalized) return;
352
- pieces.push(normalized);
353
- runs.push({
354
- text: normalized,
355
- lineIndex,
356
- computed: extractTextRunStyles(window.getComputedStyle(styleEl)),
357
- });
358
  }
359
 
360
- function walkText(node, styleEl) {
361
- if (node.nodeType === Node.TEXT_NODE) {
362
- pushText(node.textContent || '', styleEl);
363
- return;
364
  }
365
 
366
- if (node.nodeType !== Node.ELEMENT_NODE) return;
367
-
368
- const element = node;
369
- const tagName = element.tagName.toLowerCase();
370
-
371
- if (tagName === 'br') {
372
- pieces.push('\n');
373
- lineIndex++;
374
- return;
375
  }
376
 
377
- const nextStyleEl = element;
378
- for (const child of element.childNodes) {
379
- walkText(child, nextStyleEl);
380
  }
381
- }
382
 
383
- for (const child of el.childNodes) {
384
- walkText(child, el);
385
  }
386
 
387
- const text = pieces
388
- .join('')
389
- .replace(/[ \t]*\n[ \t]*/g, '\n')
390
- .replace(/[ \t]{2,}/g, ' ')
391
- .trim();
392
-
393
- return { text, runs };
394
- }
395
-
396
- function extractTextRunStyles(cs) {
397
- return {
398
- display: cs.display,
399
- position: cs.position,
400
- fontFamily: cs.fontFamily,
401
- fontSize: cs.fontSize,
402
- fontWeight: cs.fontWeight,
403
- fontStyle: cs.fontStyle,
404
- lineHeight: cs.lineHeight,
405
- letterSpacing: cs.letterSpacing,
406
- textAlign: cs.textAlign,
407
- textTransform: cs.textTransform,
408
- color: cs.color,
409
- opacity: cs.opacity,
410
- textDecoration: cs.textDecoration,
411
- webkitTextStrokeWidth: cs.webkitTextStrokeWidth,
412
- webkitTextStrokeColor: cs.webkitTextStrokeColor,
413
- };
414
- }
415
-
416
- function serializeSvgElement(svgEl, rect) {
417
- const clone = svgEl.cloneNode(true);
418
 
419
- clone.setAttribute('xmlns', clone.getAttribute('xmlns') || 'http://www.w3.org/2000/svg');
420
- clone.setAttribute('width', formatSvgNumber(rect.width));
421
- clone.setAttribute('height', formatSvgNumber(rect.height));
422
- clone.removeAttribute('opacity');
423
- if (clone.style) {
424
- clone.style.removeProperty('opacity');
425
  }
426
 
427
- inlineSvgPresentationStyles(svgEl, clone);
428
-
429
- return new XMLSerializer().serializeToString(clone);
430
- }
431
-
432
- function inlineSvgPresentationStyles(sourceRoot, cloneRoot) {
433
- const sourceElements = [sourceRoot].concat(Array.from(sourceRoot.querySelectorAll('*')));
434
- const cloneElements = [cloneRoot].concat(Array.from(cloneRoot.querySelectorAll('*')));
435
-
436
- for (let index = 0; index < sourceElements.length; index++) {
437
- const sourceEl = sourceElements[index];
438
- const cloneEl = cloneElements[index];
439
- if (!sourceEl || !cloneEl) continue;
440
-
441
- cloneEl.removeAttribute('data-tempelhtml-animated');
442
- const cs = window.getComputedStyle(sourceEl);
443
- const isRoot = index === 0;
444
-
445
- setSvgPresentationAttribute(cloneEl, 'fill', cs.fill);
446
- setSvgPresentationAttribute(cloneEl, 'stroke', cs.stroke);
447
- setSvgPresentationAttribute(cloneEl, 'stroke-width', cs.strokeWidth);
448
- setSvgPresentationAttribute(cloneEl, 'stroke-linecap', cs.strokeLinecap);
449
- setSvgPresentationAttribute(cloneEl, 'stroke-linejoin', cs.strokeLinejoin);
450
- setSvgPresentationAttribute(cloneEl, 'stroke-miterlimit', cs.strokeMiterlimit);
451
- setSvgPresentationAttribute(cloneEl, 'stroke-dasharray', cs.strokeDasharray);
452
- setSvgPresentationAttribute(cloneEl, 'fill-rule', cs.fillRule);
453
- setSvgPresentationAttribute(cloneEl, 'clip-rule', cs.clipRule);
454
- setSvgPresentationAttribute(cloneEl, 'vector-effect', cs.vectorEffect);
455
-
456
- if (!isRoot) {
457
- setSvgPresentationAttribute(cloneEl, 'opacity', cs.opacity);
458
- setSvgPresentationAttribute(cloneEl, 'fill-opacity', cs.fillOpacity);
459
- setSvgPresentationAttribute(cloneEl, 'stroke-opacity', cs.strokeOpacity);
460
  }
461
- }
462
- }
463
 
464
- function setSvgPresentationAttribute(el, name, value) {
465
- if (!isUsableSvgPresentationValue(value)) {
466
- return;
467
  }
468
 
469
- el.setAttribute(name, normalizeSvgPresentationValue(value));
470
- }
471
-
472
- function isUsableSvgPresentationValue(value) {
473
- if (value === undefined || value === null) {
474
- return false;
475
  }
476
-
477
- const normalized = String(value).trim();
478
- return normalized !== '' && normalized !== 'normal' && normalized !== 'auto';
479
- }
480
-
481
- function normalizeSvgPresentationValue(value) {
482
- return String(value).trim();
483
- }
484
-
485
- function formatSvgNumber(value) {
486
- const number = Number(value);
487
- if (!Number.isFinite(number)) {
488
- return '1';
489
- }
490
- return String(Math.max(Math.round(number * 1000) / 1000, 1));
491
- }
492
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  function getChildNode(child, parentEl, parentStyles, depth) {
494
  if (child.nodeType === Node.TEXT_NODE) {
495
- return getDirectTextNode(child, parentStyles);
496
  }
497
 
498
  if (child.nodeType === Node.ELEMENT_NODE) {
499
- return getNode(child, depth);
500
- }
501
-
502
- return null;
503
- }
504
-
505
- function getDirectTextNode(textNode, parentStyles) {
506
- const normalizedText = normalizeTextFragment(textNode.textContent || '').trim();
507
- if (!normalizedText) {
508
- return null;
509
- }
510
-
511
- const range = document.createRange();
512
- range.selectNodeContents(textNode);
513
- const rect = range.getBoundingClientRect();
514
- if (rect.width === 0 && rect.height === 0) {
515
- return null;
516
- }
517
-
518
- const computed = extractRelevantStyles(parentStyles);
519
- computed.display = 'inline';
520
- computed.position = 'static';
521
- computed.width = `${rect.width}px`;
522
- computed.height = `${rect.height}px`;
523
- computed.minWidth = '0px';
524
- computed.minHeight = '0px';
525
-
526
- return {
527
- tag: 'span',
528
- id: null,
529
- classList: [],
530
- text: normalizedText,
531
- textRuns: [{
532
- text: normalizedText,
533
- lineIndex: 0,
534
- computed: extractTextRunStyles(parentStyles),
535
- }],
536
- isTextContainer: true,
537
- rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
538
- computed,
539
- pseudo: {
540
- before: null,
541
- after: null,
542
- },
543
- children: [],
544
- };
545
- }
546
-
547
- function normalizeTextFragment(text) {
548
- return String(text || '')
549
- .replace(/\r/g, '')
550
- .replace(/\u00a0/g, ' ')
551
- .replace(/\s+/g, ' ');
552
- }
553
-
554
- function isInlineTextChild(child) {
555
- if (!child || child.nodeType !== Node.ELEMENT_NODE) return false;
556
- const childTag = child.tagName.toLowerCase();
557
- if (!INLINE_TAGS.has(childTag)) return false;
558
-
559
- const childCs = window.getComputedStyle(child);
560
- if (childCs.position !== 'static') return false;
561
- if (childCs.display !== 'inline' && childCs.display !== 'contents') return false;
562
- return !hasVisualBoxForStyles(childCs);
563
- }
564
-
565
- function hasVisualBoxForStyles(cs) {
566
- return !isTransparentColor(cs.backgroundColor) ||
567
- cs.backgroundImage !== 'none' ||
568
- cs.borderStyle !== 'none' ||
569
- parseFloat(cs.borderTopWidth) > 0 ||
570
- parseFloat(cs.borderRightWidth) > 0 ||
571
- parseFloat(cs.borderBottomWidth) > 0 ||
572
- parseFloat(cs.borderLeftWidth) > 0 ||
573
- parseFloat(cs.paddingTop) > 0 ||
574
- parseFloat(cs.paddingRight) > 0 ||
575
- parseFloat(cs.paddingBottom) > 0 ||
576
- parseFloat(cs.paddingLeft) > 0 ||
577
- cs.boxShadow !== 'none';
578
- }
579
-
580
- function hasRenderablePseudo(cs) {
581
- if (!cs || cs.content === 'none' || cs.content === 'normal') {
582
- return false;
583
- }
584
-
585
- return parseCssContent(cs.content) !== '' || hasSupportedPseudoVisual(cs);
586
- }
587
-
588
- function isVisuallyHiddenPseudo(cs, rect = null, parentRect = null, parentStyles = null) {
589
- if (!cs) {
590
- return true;
591
- }
592
-
593
- const opacity = parseFloat(cs.opacity);
594
- if (cs.display === 'none' || cs.visibility === 'hidden' || (Number.isFinite(opacity) && opacity <= 0)) {
595
- return true;
596
- }
597
-
598
- if (hasCollapsedTransform(cs.transform)) {
599
- return true;
600
- }
601
-
602
- if (rect && isFullyClippedByClipPath(cs.clipPath, rect)) {
603
- return true;
604
- }
605
-
606
- if (rect && parentRect && parentStyles && isClippedOutsideParent(rect, parentRect, parentStyles)) {
607
- return true;
608
- }
609
-
610
- return false;
611
- }
612
-
613
- function hasCollapsedTransform(transformValue) {
614
- if (!transformValue || transformValue === 'none') {
615
- return false;
616
- }
617
-
618
- const scale = parseTransformScale(transformValue);
619
- if (!scale) {
620
- return false;
621
- }
622
-
623
- const tolerance = 0.001;
624
- return scale.x <= tolerance || scale.y <= tolerance;
625
- }
626
-
627
- function parseTransformScale(transformValue) {
628
- const value = String(transformValue).trim();
629
- const matrixMatch = value.match(/^matrix\(([^)]+)\)$/i);
630
- if (matrixMatch) {
631
- const values = parseTransformNumbers(matrixMatch[1]);
632
- if (values.length === 6) {
633
- return {
634
- x: Math.hypot(values[0], values[1]),
635
- y: Math.hypot(values[2], values[3]),
636
- };
637
  }
638
- }
639
 
640
- const matrix3dMatch = value.match(/^matrix3d\(([^)]+)\)$/i);
641
- if (matrix3dMatch) {
642
- const values = parseTransformNumbers(matrix3dMatch[1]);
643
- if (values.length === 16) {
644
- return {
645
- x: Math.hypot(values[0], values[1], values[2]),
646
- y: Math.hypot(values[4], values[5], values[6]),
647
- };
648
  }
649
- }
650
-
651
- return parseScaleFunction(value);
652
- }
653
-
654
- function parseTransformNumbers(value) {
655
- return String(value)
656
- .split(',')
657
- .map((part) => parseFloat(part.trim()))
658
- .filter((number) => Number.isFinite(number));
659
- }
660
 
661
- function parseScaleFunction(value) {
662
- const scaleX = value.match(/scaleX\(\s*([-+]?\d*\.?\d+)/i);
663
- const scaleY = value.match(/scaleY\(\s*([-+]?\d*\.?\d+)/i);
664
- const scale = value.match(/scale\(\s*([-+]?\d*\.?\d+)(?:\s*,\s*([-+]?\d*\.?\d+))?/i);
665
-
666
- if (scaleX || scaleY || scale) {
667
- const uniformScale = scale ? Math.abs(parseFloat(scale[1])) : 1;
668
- return {
669
- x: scaleX ? Math.abs(parseFloat(scaleX[1])) : uniformScale,
670
- y: scaleY ? Math.abs(parseFloat(scaleY[1])) : (scale?.[2] ? Math.abs(parseFloat(scale[2])) : uniformScale),
671
- };
672
  }
673
 
674
  return null;
675
  }
676
 
677
- function extractPseudoElementData(el, tag, parentStyles, pseudoStyles, pseudoType) {
678
- if (!hasRenderablePseudo(pseudoStyles)) {
679
- return null;
680
- }
681
-
682
- const content = parseCssContent(pseudoStyles.content);
683
- if (!content && !hasSupportedPseudoVisual(pseudoStyles)) {
684
- return null;
685
- }
686
-
687
- const parentRect = el.getBoundingClientRect();
688
- const rect = estimatePseudoTextRect(parentRect, parentStyles, pseudoStyles, pseudoType);
689
- const transformedRect = applyPseudoTransformRect(rect, pseudoStyles.transform);
690
- const finalRect = transformedRect || rect;
691
-
692
- if (isVisuallyHiddenPseudo(pseudoStyles, finalRect, parentRect, parentStyles)) {
693
- return null;
694
- }
695
-
696
- if (!content && (finalRect.width <= 0 || finalRect.height <= 0)) {
697
- return null;
698
- }
699
-
700
- if (finalRect.width === 0 && finalRect.height === 0) {
701
  return null;
702
  }
703
-
704
- return {
705
- name: `${buildPseudoName(el, tag)}::${pseudoType}`,
706
- type: content ? 'text' : 'box',
707
- content: content || null,
708
- rect: finalRect,
709
- fillColor: pseudoStyles.color,
710
- opacity: Number.isFinite(parseFloat(pseudoStyles.opacity)) ? parseFloat(pseudoStyles.opacity) : 1,
711
- position: pseudoStyles.position,
712
- zOrder: resolvePseudoZOrder(pseudoStyles, pseudoType),
713
- computed: extractRelevantStyles(pseudoStyles),
714
- };
715
- }
716
-
717
- function resolvePseudoZOrder(pseudoStyles, pseudoType) {
718
- const zIndex = parseFloat(pseudoStyles.zIndex);
719
- if (Number.isFinite(zIndex)) {
720
- return zIndex < 0 ? 'bottom' : 'top';
721
- }
722
-
723
- return pseudoType === 'before' ? 'bottom' : 'top';
724
- }
725
-
726
- function hasSupportedPseudoVisual(cs) {
727
- return !isTransparentColor(cs.backgroundColor) ||
728
- String(cs.backgroundImage || '').includes('linear-gradient') ||
729
- cs.borderStyle !== 'none' ||
730
- parseFloat(cs.borderTopWidth) > 0 ||
731
- parseFloat(cs.borderRightWidth) > 0 ||
732
- parseFloat(cs.borderBottomWidth) > 0 ||
733
- parseFloat(cs.borderLeftWidth) > 0 ||
734
- cs.boxShadow !== 'none';
735
- }
736
-
737
- function estimatePseudoTextRect(parentRect, parentStyles, pseudoStyles, pseudoType) {
738
- const width = parseCssPx(pseudoStyles.width);
739
- const height = parseCssPx(pseudoStyles.height) || parseCssPx(pseudoStyles.lineHeight) || parseCssPx(pseudoStyles.fontSize);
740
- const position = pseudoStyles.position;
741
-
742
- if (position === 'absolute' || position === 'fixed') {
743
- return estimatePositionedPseudoRect(parentRect, pseudoStyles, width, height);
744
- }
745
-
746
- if (parentStyles.display === 'flex' || parentStyles.display === 'inline-flex') {
747
- return estimateFlexPseudoRect(parentRect, parentStyles, width, height, pseudoType);
748
- }
749
-
750
- return {
751
- x: pseudoType === 'before' ? parentRect.x : parentRect.right - width,
752
- y: parentRect.y + Math.max((parentRect.height - height) / 2, 0),
753
- width,
754
- height,
755
- };
756
- }
757
-
758
- function applyPseudoTransformRect(rect, transformValue) {
759
- const matrix = parseCssTransformMatrix(transformValue);
760
- if (!matrix) {
761
- return rect;
762
- }
763
-
764
- const points = [
765
- transformPoint(matrix, rect.x, rect.y),
766
- transformPoint(matrix, rect.x + rect.width, rect.y),
767
- transformPoint(matrix, rect.x, rect.y + rect.height),
768
- transformPoint(matrix, rect.x + rect.width, rect.y + rect.height),
769
- ];
770
-
771
- const xs = points.map((point) => point.x);
772
- const ys = points.map((point) => point.y);
773
- const minX = Math.min(...xs);
774
- const maxX = Math.max(...xs);
775
- const minY = Math.min(...ys);
776
- const maxY = Math.max(...ys);
777
-
778
- return {
779
- x: minX,
780
- y: minY,
781
- width: Math.max(maxX - minX, 0),
782
- height: Math.max(maxY - minY, 0),
783
- };
784
- }
785
-
786
- function parseCssTransformMatrix(transformValue) {
787
- const value = String(transformValue || '').trim();
788
- if (!value || value === 'none') {
789
  return null;
790
  }
791
 
792
- const matrixMatch = value.match(/^matrix\(([^)]+)\)$/i);
793
- if (matrixMatch) {
794
- const values = parseTransformNumbers(matrixMatch[1]);
795
- if (values.length === 6) {
796
- return {
797
- a: values[0],
798
- b: values[1],
799
- c: values[2],
800
- d: values[3],
801
- e: values[4],
802
- f: values[5],
803
- };
804
  }
805
  }
806
 
807
- const matrix3dMatch = value.match(/^matrix3d\(([^)]+)\)$/i);
808
- if (matrix3dMatch) {
809
- const values = parseTransformNumbers(matrix3dMatch[1]);
810
- if (values.length === 16) {
811
- return {
812
- a: values[0],
813
- b: values[1],
814
- c: values[4],
815
- d: values[5],
816
- e: values[12],
817
- f: values[13],
818
- };
819
- }
820
- }
821
-
822
- return null;
823
- }
824
-
825
- function transformPoint(matrix, x, y) {
826
- return {
827
- x: (matrix.a * x) + (matrix.c * y) + matrix.e,
828
- y: (matrix.b * x) + (matrix.d * y) + matrix.f,
829
- };
830
- }
831
-
832
- function isFullyClippedByClipPath(clipPath, rect) {
833
- const value = String(clipPath || '').trim();
834
- if (!value || value === 'none') {
835
- return false;
836
- }
837
-
838
- const insetMatch = value.match(/^inset\((.+)\)$/i);
839
- if (!insetMatch) {
840
- return false;
841
- }
842
-
843
- const parts = splitInsetTokens(insetMatch[1]);
844
- const [topToken, rightToken, bottomToken, leftToken] = normalizeInsetTokens(parts);
845
- const top = resolveInsetValue(topToken, rect.height);
846
- const right = resolveInsetValue(rightToken, rect.width);
847
- const bottom = resolveInsetValue(bottomToken, rect.height);
848
- const left = resolveInsetValue(leftToken, rect.width);
849
-
850
- return rect.width - left - right <= 0 || rect.height - top - bottom <= 0;
851
- }
852
-
853
- function splitInsetTokens(value) {
854
- return String(value)
855
- .split(/\s+round\s+/i)[0]
856
- .trim()
857
- .split(/\s+/)
858
- .filter(Boolean);
859
- }
860
-
861
- function normalizeInsetTokens(tokens) {
862
- if (tokens.length === 1) {
863
- return [tokens[0], tokens[0], tokens[0], tokens[0]];
864
- }
865
- if (tokens.length === 2) {
866
- return [tokens[0], tokens[1], tokens[0], tokens[1]];
867
- }
868
- if (tokens.length === 3) {
869
- return [tokens[0], tokens[1], tokens[2], tokens[1]];
870
- }
871
- return [tokens[0], tokens[1], tokens[2], tokens[3]];
872
- }
873
-
874
- function resolveInsetValue(token, size) {
875
- const value = String(token || '').trim();
876
- if (!value || value === 'auto') {
877
- return 0;
878
- }
879
- if (value.endsWith('%')) {
880
- const ratio = parseFloat(value);
881
- return Number.isFinite(ratio) ? (ratio / 100) * size : 0;
882
- }
883
- const parsed = parseFloat(value);
884
- return Number.isFinite(parsed) ? parsed : 0;
885
- }
886
-
887
- function isClippedOutsideParent(rect, parentRect, parentStyles) {
888
- if (!clippingEnabled(parentStyles)) {
889
- return false;
890
- }
891
-
892
- const intersectionWidth = Math.min(rect.x + rect.width, parentRect.x + parentRect.width) - Math.max(rect.x, parentRect.x);
893
- const intersectionHeight = Math.min(rect.y + rect.height, parentRect.y + parentRect.height) - Math.max(rect.y, parentRect.y);
894
-
895
- return intersectionWidth <= 0.5 || intersectionHeight <= 0.5;
896
- }
897
-
898
- function clippingEnabled(parentStyles) {
899
- if (!parentStyles) {
900
- return false;
901
- }
902
-
903
- return ['overflow', 'overflowX', 'overflowY'].some((prop) => {
904
- const value = String(parentStyles[prop] || '').toLowerCase();
905
- return value === 'hidden' || value === 'clip' || value === 'scroll' || value === 'auto';
906
- });
907
- }
908
-
909
- function estimatePositionedPseudoRect(parentRect, pseudoStyles, width, height) {
910
- const left = pseudoStyles.left !== 'auto' ? parseCssPx(pseudoStyles.left) : null;
911
- const right = pseudoStyles.right !== 'auto' ? parseCssPx(pseudoStyles.right) : null;
912
- const top = pseudoStyles.top !== 'auto' ? parseCssPx(pseudoStyles.top) : null;
913
- const bottom = pseudoStyles.bottom !== 'auto' ? parseCssPx(pseudoStyles.bottom) : null;
914
-
915
- return {
916
- x: parentRect.x + (left !== null ? left : parentRect.width - width - (right || 0)),
917
- y: parentRect.y + (top !== null ? top : parentRect.height - height - (bottom || 0)),
918
- width,
919
- height,
920
- };
921
- }
922
-
923
- function estimateFlexPseudoRect(parentRect, parentStyles, width, height, pseudoType) {
924
- const isRow = parentStyles.flexDirection !== 'column' && parentStyles.flexDirection !== 'column-reverse';
925
- const isReverse = parentStyles.flexDirection === 'row-reverse' || parentStyles.flexDirection === 'column-reverse';
926
- const isEnd = (pseudoType === 'after') !== isReverse;
927
-
928
- if (isRow) {
929
- return {
930
- x: isEnd ? parentRect.right - width : parentRect.x,
931
- y: alignCrossAxis(parentRect.y, parentRect.height, height, parentStyles.alignItems),
932
- width,
933
- height,
934
- };
935
- }
936
-
937
- return {
938
- x: alignCrossAxis(parentRect.x, parentRect.width, width, parentStyles.alignItems),
939
- y: isEnd ? parentRect.bottom - height : parentRect.y,
940
- width,
941
- height,
942
- };
943
- }
944
-
945
- function alignCrossAxis(start, parentSize, childSize, alignItems) {
946
- if (alignItems === 'center') {
947
- return start + Math.max((parentSize - childSize) / 2, 0);
948
- }
949
- if (alignItems === 'flex-end') {
950
- return start + Math.max(parentSize - childSize, 0);
951
- }
952
- return start;
953
- }
954
-
955
- function parseCssContent(value) {
956
- if (!value || value === 'none' || value === 'normal') return '';
957
- const trimmed = String(value).trim();
958
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
959
- return trimmed.slice(1, -1)
960
- .replace(/\\"/g, '"')
961
- .replace(/\\'/g, "'");
962
- }
963
- return trimmed;
964
- }
965
-
966
- function parseCssPx(value) {
967
- if (!value || value === 'auto' || value === 'normal' || value === 'none') return 0;
968
- const parsed = parseFloat(value);
969
- return Number.isFinite(parsed) ? parsed : 0;
970
- }
971
-
972
- function buildPseudoName(el, tag) {
973
- const classPart = Array.from(el.classList || []).slice(0, 2).join('.');
974
- return classPart ? `${tag}.${classPart}` : tag;
975
- }
976
-
977
- function canCollapseToTextContainer(el, tag, cs, hasOnlyInlineTextChildren) {
978
- const hasElementChildren = el.children.length > 0;
979
- if (!hasElementChildren) {
980
- return true;
981
- }
982
-
983
- if (!hasOnlyInlineTextChildren) {
984
- return false;
985
- }
986
-
987
- return TEXT_TAGS.has(tag) || tag === 'div';
988
- }
989
-
990
- function normalizeTextContent(value) {
991
- return String(value || '')
992
- .replace(/\r/g, '')
993
- .replace(/\u00a0/g, ' ')
994
- .replace(/[ \t]+\n/g, '\n')
995
- .replace(/\n[ \t]+/g, '\n')
996
- .replace(/[ \t]{2,}/g, ' ')
997
- .trim();
998
- }
999
-
1000
- function normalizeFormControlText(value, preserveLineBreaks = false) {
1001
- const text = String(value || '').replace(/\r/g, '');
1002
- if (preserveLineBreaks) {
1003
- return text.trim();
1004
- }
1005
- return normalizeTextContent(text);
1006
- }
1007
-
1008
- function isTransparentColor(value) {
1009
- return !value || value === 'transparent' || value === 'none' || value === 'rgba(0, 0, 0, 0)';
1010
- }
1011
-
1012
- return getNode(document.body);
1013
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * src/core/extractor.js
3
+ * Renders HTML in headless Playwright, extracts computed styles
4
+ * and bounding rects for every DOM element.
5
+ */
6
+
7
+ import { chromium } from 'playwright-core';
8
+ import { existsSync, statSync } from 'fs';
9
+ import { dirname, resolve } from 'path';
10
+ import { pathToFileURL } from 'url';
11
+
12
+ /**
13
+ * @param {string} filePath - absolute or relative path to HTML file
14
+ * @param {{ width: number, height: number }} viewport
15
+ * @returns {{ domTree, title: string }}
16
+ */
17
+ export async function extractFromFile(filePath, { width = 1440, height = 900 } = {}) {
18
+ const absPath = resolve(filePath);
19
+ const browser = await chromium.launch();
20
+ const page = await browser.newPage({ viewport: { width, height } });
21
+
22
+ await page.goto(pathToFileURL(absPath).href);
23
+ const result = await extractFromPage(page);
24
+ await browser.close();
25
+ return result;
26
+ }
27
+
28
+ /**
29
+ * @param {string} html
30
+ * @param {{ width?: number, height?: number, baseUrl?: string | null }} options
31
+ * @returns {{ domTree, title: string }}
32
+ */
33
+ export async function extractFromHtml(html, { width = 1440, height = 900, baseUrl = null } = {}) {
34
+ const browser = await chromium.launch();
35
+ const page = await browser.newPage({ viewport: { width, height } });
36
+ const htmlWithBase = injectBaseHref(html, normalizeBaseUrl(baseUrl));
37
+
38
+ await page.setContent(htmlWithBase, { waitUntil: 'load' });
39
+ const result = await extractFromPage(page);
40
+ await browser.close();
41
+ return result;
42
+ }
43
+
44
+ async function extractFromPage(page) {
45
+ await stabilizePage(page);
46
+
47
+ // Walk the full DOM and capture computed styles + rects
48
+ const title = await page.title();
49
+ const domTree = await page.evaluate(walkDOMInBrowser);
50
+ return { domTree, title };
51
+ }
52
+
53
+ async function stabilizePage(page) {
54
+ await page.waitForLoadState('networkidle');
55
+
56
+ await page.evaluate(() => {
57
+ document.querySelectorAll('.reveal').forEach(el => el.classList.add('visible'));
58
+
59
+ const animated = Array.from(document.querySelectorAll('*')).filter((el) => {
60
+ const cs = window.getComputedStyle(el);
61
+ return cs.animationName !== 'none' || cs.transitionDuration !== '0s';
62
+ });
63
+ animated.forEach((el) => el.setAttribute('data-morphus-animated', '1'));
64
+
65
+ const style = document.createElement('style');
66
+ style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
67
+ document.head.appendChild(style);
68
+
69
+ document.querySelectorAll('[data-morphus-animated="1"]').forEach((el) => {
70
  const cs = window.getComputedStyle(el);
71
+ if (cs.opacity === '0' && shouldForceAnimatedElementVisible(el, cs)) {
72
  el.style.opacity = '1';
73
+ if (isTranslateOnlyTransform(cs.transform)) {
74
+ el.style.transform = 'none';
75
+ }
76
  }
77
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
+ limitPaginatedTableRows();
 
80
 
81
+ function shouldForceAnimatedElementVisible(el, cs) {
82
+ if (cs.display === 'none' || cs.visibility === 'hidden') {
83
+ return false;
84
+ }
85
+
86
+ if (cs.position === 'absolute' || cs.position === 'fixed') {
87
+ return false;
88
+ }
89
+
90
+ if (el.closest('[hidden], [aria-hidden="true"], [inert]')) {
91
+ return false;
92
+ }
93
+
94
+ if (hasLiveOrProgressSemantics(el)) {
95
+ return false;
96
+ }
97
+
98
+ const rect = el.getBoundingClientRect();
99
+ return rect.width > 0 && rect.height > 0;
100
+ }
101
+
102
+ function hasLiveOrProgressSemantics(el) {
103
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) {
104
+ return false;
105
+ }
106
+
107
+ const role = String(el.getAttribute('role') || '').toLowerCase();
108
+ if (role === 'progressbar' || role === 'status' || role === 'alert') {
109
+ return true;
110
+ }
111
+
112
+ if (el.hasAttribute('aria-busy') || el.hasAttribute('aria-live')) {
113
+ return true;
114
+ }
115
+
116
+ return Boolean(el.querySelector('progress, meter, [role="progressbar"], [aria-busy], [aria-live]'));
117
+ }
118
+
119
+ function isTranslateOnlyTransform(value) {
120
+ if (!value || value === 'none') {
121
+ return false;
122
+ }
123
+
124
+ const text = String(value).trim();
125
+ const matrixMatch = text.match(/^matrix\(([^)]+)\)$/i);
126
+ if (matrixMatch) {
127
+ const values = matrixMatch[1]
128
+ .split(',')
129
+ .map((part) => parseFloat(part.trim()));
130
+ if (values.length === 6 && values.every((number) => Number.isFinite(number))) {
131
+ const tolerance = 0.001;
132
+ return Math.abs(values[0] - 1) <= tolerance
133
+ && Math.abs(values[1]) <= tolerance
134
+ && Math.abs(values[2]) <= tolerance
135
+ && Math.abs(values[3] - 1) <= tolerance;
136
+ }
137
+ }
138
+
139
+ return /^translate(?:3d|x|y)?\(/i.test(text);
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  }
141
 
142
+ function limitPaginatedTableRows() {
143
+ const pagers = Array.from(document.querySelectorAll('*')).filter((el) => isPaginationElement(el));
144
+
145
+ for (const pager of pagers) {
146
+ const pagerRect = pager.getBoundingClientRect();
147
+ if (pagerRect.width <= 0 || pagerRect.height <= 0) {
148
+ continue;
149
+ }
150
+
151
+ const container = findPaginatedDataContainer(pager, pagerRect);
152
+ if (!container) {
153
+ continue;
154
+ }
155
+
156
+ const rows = getDataRows(container).filter((row) => !pager.contains(row));
157
+ if (rows.length < 8) {
158
+ continue;
159
+ }
160
+
161
+ const cutoffY = pagerRect.bottom - 1;
162
+ let hiddenCount = 0;
163
+ for (const row of rows) {
164
+ const rect = row.getBoundingClientRect();
165
+ if (rect.width <= 0 || rect.height <= 0) {
166
+ continue;
167
+ }
168
+ if (rect.top >= cutoffY) {
169
+ row.setAttribute('data-morphus-paginated-row-clipped', '1');
170
+ row.style.display = 'none';
171
+ hiddenCount++;
172
+ }
173
+ }
174
+
175
+ if (hiddenCount > 0) {
176
+ container.setAttribute('data-morphus-paginated-preview', '1');
177
+ }
178
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  }
180
 
181
+ function isPaginationElement(el) {
182
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) {
183
+ return false;
 
184
  }
185
 
186
+ const identity = `${el.id || ''} ${String(el.className || '')} ${el.getAttribute('role') || ''}`.toLowerCase();
187
+ if (/(pagination|paginator|pager|page-nav|page-control)/.test(identity)) {
188
+ return true;
 
 
 
 
 
 
189
  }
190
 
191
+ const text = normalizePaginationText(el.innerText || el.textContent || '');
192
+ if (!text || text.length > 160) {
193
+ return false;
194
  }
 
195
 
196
+ return /\b(hal|page)\s*\d+\s*\/\s*\d+\b/i.test(text)
197
+ || /\b(baris|rows?)\s+\d+\s*[–-]\s*\d+\b/i.test(text);
198
  }
199
 
200
+ function findPaginatedDataContainer(pager, pagerRect) {
201
+ let current = pager.parentElement;
202
+ while (current && current !== document.documentElement) {
203
+ const rows = getDataRows(current).filter((row) => !pager.contains(row));
204
+ if (rows.length >= 8) {
205
+ const before = rows.filter((row) => {
206
+ const rect = row.getBoundingClientRect();
207
+ return rect.width > 0 && rect.height > 0 && rect.bottom <= pagerRect.top + 1;
208
+ });
209
+ const after = rows.filter((row) => {
210
+ const rect = row.getBoundingClientRect();
211
+ return rect.width > 0 && rect.height > 0 && rect.top >= pagerRect.bottom - 1;
212
+ });
213
+
214
+ if (before.length >= 3 && after.length >= 1) {
215
+ return current;
216
+ }
217
+ }
218
+
219
+ current = current.parentElement;
220
+ }
 
 
 
 
 
 
 
 
 
 
221
 
222
+ return null;
 
 
 
 
 
223
  }
224
 
225
+ function getDataRows(container) {
226
+ const seen = new Set();
227
+ const rows = [];
228
+ const selectors = ['tr', '[role="row"]'];
229
+
230
+ for (const selector of selectors) {
231
+ for (const row of container.querySelectorAll(selector)) {
232
+ if (seen.has(row) || row.closest('thead')) {
233
+ continue;
234
+ }
235
+ seen.add(row);
236
+ rows.push(row);
237
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  }
 
 
239
 
240
+ return rows;
 
 
241
  }
242
 
243
+ function normalizePaginationText(value) {
244
+ return String(value || '').replace(/\s+/g, ' ').trim();
 
 
 
 
245
  }
246
+ });
247
+
248
+ await waitForCanvasPaint(page);
249
+ }
250
+
251
+ async function waitForCanvasPaint(page) {
252
+ const canvasCount = await page.locator('canvas').count();
253
+ if (canvasCount === 0) {
254
+ return;
255
+ }
256
+
257
+ try {
258
+ await page.waitForFunction(() => {
259
+ const canvases = Array.from(document.querySelectorAll('canvas'))
260
+ .filter((canvas) => {
261
+ const rect = canvas.getBoundingClientRect();
262
+ const cs = window.getComputedStyle(canvas);
263
+ const opacity = parseFloat(cs.opacity);
264
+ return rect.width > 0
265
+ && rect.height > 0
266
+ && cs.display !== 'none'
267
+ && cs.visibility !== 'hidden'
268
+ && (!Number.isFinite(opacity) || opacity > 0);
269
+ });
270
+
271
+ if (canvases.length === 0) {
272
+ return true;
273
+ }
274
+
275
+ const now = performance.now();
276
+ const state = window.__morphusCanvasCaptureState || {
277
+ startedAt: now,
278
+ lastSignature: '',
279
+ stableCount: 0,
280
+ };
281
+
282
+ const snapshots = canvases.map((canvas) => captureCanvasSnapshot(canvas));
283
+ const readableSnapshots = snapshots.filter((snapshot) => snapshot.readable);
284
+ if (readableSnapshots.length === 0) {
285
+ return true;
286
+ }
287
+
288
+ const signature = readableSnapshots.map((snapshot) => snapshot.signature).join('|');
289
+ if (signature && signature === state.lastSignature) {
290
+ state.stableCount += 1;
291
+ } else {
292
+ state.lastSignature = signature;
293
+ state.stableCount = 0;
294
+ }
295
+
296
+ window.__morphusCanvasCaptureState = state;
297
+ return state.stableCount >= 2 && now - state.startedAt >= 500;
298
+
299
+ function captureCanvasSnapshot(canvas) {
300
+ const width = Math.floor(canvas.width || 0);
301
+ const height = Math.floor(canvas.height || 0);
302
+ if (width <= 0 || height <= 0) {
303
+ return { readable: false };
304
+ }
305
+
306
+ let src = '';
307
+ try {
308
+ src = canvas.toDataURL('image/png');
309
+ } catch (err) {
310
+ return { readable: false };
311
+ }
312
+
313
+ canvas.__morphusCanvasCaptureSrc = src;
314
+ return {
315
+ readable: Boolean(src && src !== 'data:,'),
316
+ signature: `${width}x${height}:${src.length}:${hashString(src)}`,
317
+ };
318
+ }
319
+
320
+ function hashString(value) {
321
+ let hash = 2166136261;
322
+ const text = String(value || '');
323
+ for (let index = 0; index < text.length; index++) {
324
+ hash ^= text.charCodeAt(index);
325
+ hash = Math.imul(hash, 16777619);
326
+ }
327
+ return hash >>> 0;
328
+ }
329
+ }, null, { timeout: 2500, polling: 80 });
330
+ } catch (err) {
331
+ // Continue with the best available canvas state instead of failing conversion.
332
+ }
333
+ }
334
+
335
+ function normalizeBaseUrl(baseUrl) {
336
+ if (!baseUrl) return null;
337
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(baseUrl)) return baseUrl;
338
+
339
+ const absPath = resolve(baseUrl);
340
+ const targetPath = existsSync(absPath) && !statSync(absPath).isDirectory()
341
+ ? dirname(absPath)
342
+ : absPath;
343
+
344
+ let href = pathToFileURL(targetPath).href;
345
+ if (!href.endsWith('/')) {
346
+ href += '/';
347
+ }
348
+ return href;
349
+ }
350
+
351
+ function injectBaseHref(html, baseHref) {
352
+ if (!baseHref || /<base\s/i.test(html)) {
353
+ return html;
354
+ }
355
+
356
+ const baseTag = `<base href="${escapeHtmlAttribute(baseHref)}">`;
357
+ if (/<head[^>]*>/i.test(html)) {
358
+ return html.replace(/<head([^>]*)>/i, `<head$1>\n${baseTag}`);
359
+ }
360
+
361
+ if (/<html[^>]*>/i.test(html)) {
362
+ return html.replace(/<html([^>]*)>/i, `<html$1><head>${baseTag}</head>`);
363
+ }
364
+
365
+ return `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
366
+ }
367
+
368
+ function escapeHtmlAttribute(value) {
369
+ return String(value)
370
+ .replace(/&/g, '&amp;')
371
+ .replace(/"/g, '&quot;');
372
+ }
373
+
374
+ /**
375
+ * This function is serialized and run inside the browser context.
376
+ * It must be self-contained (no imports).
377
+ */
378
+ function walkDOMInBrowser() {
379
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'HEAD', 'NOSCRIPT']);
380
+ const TEXT_TAGS = new Set(['p', 'span', 'a', 'label', 'em', 'strong', 'b', 'i', 'small', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'th']);
381
+ const INLINE_TAGS = new Set(['span', 'a', 'label', 'em', 'strong', 'b', 'i', 'small', 'mark', 'sup', 'sub', 'u', 's', 'code', 'br', 'wbr']);
382
+ const TEXT_INPUT_TYPES = new Set([
383
+ '',
384
+ 'date',
385
+ 'datetime-local',
386
+ 'email',
387
+ 'month',
388
+ 'number',
389
+ 'password',
390
+ 'search',
391
+ 'tel',
392
+ 'text',
393
+ 'time',
394
+ 'url',
395
+ 'week',
396
+ ]);
397
+
398
+ function getNode(el, depth = 0) {
399
+ if (SKIP_TAGS.has(el.tagName)) return null;
400
+
401
+ const rect = el.getBoundingClientRect();
402
+ const cs = window.getComputedStyle(el);
403
+ const csBefore = window.getComputedStyle(el, '::before');
404
+ const csAfter = window.getComputedStyle(el, '::after');
405
+ const tag = el.tagName.toLowerCase();
406
+ const isSvg = tag === 'svg';
407
+ const isImage = tag === 'img';
408
+ const isCanvas = tag === 'canvas';
409
+
410
+ // Skip invisible/zero-size elements
411
+ if (rect.width === 0 && rect.height === 0 && cs.position === 'static') return null;
412
+ if (isVisuallyHiddenElement(cs)) return null;
413
+
414
+ const rawText = normalizeTextContent(el.innerText || el.textContent || '');
415
+ const hasVisualBox =
416
+ !isTransparentColor(cs.backgroundColor) ||
417
+ cs.backgroundImage !== 'none' ||
418
+ cs.borderStyle !== 'none' ||
419
+ parseFloat(cs.borderTopWidth) > 0 ||
420
+ parseFloat(cs.borderRightWidth) > 0 ||
421
+ parseFloat(cs.borderBottomWidth) > 0 ||
422
+ parseFloat(cs.borderLeftWidth) > 0 ||
423
+ parseFloat(cs.paddingTop) > 0 ||
424
+ parseFloat(cs.paddingRight) > 0 ||
425
+ parseFloat(cs.paddingBottom) > 0 ||
426
+ parseFloat(cs.paddingLeft) > 0 ||
427
+ cs.boxShadow !== 'none';
428
+ const hasOnlyInlineTextChildren = Boolean(rawText) && Array.from(el.children).length > 0 && Array.from(el.children).every((child) => isInlineTextChild(child));
429
+ const isTextContainer = Boolean(rawText)
430
+ && !hasVisualBox
431
+ && !hasRenderablePseudo(csBefore)
432
+ && !hasRenderablePseudo(csAfter)
433
+ && canCollapseToTextContainer(el, tag, cs, hasOnlyInlineTextChildren);
434
+
435
+ const textData = rawText ? extractTextData(el) : null;
436
+ const beforeData = extractPseudoElementData(el, tag, cs, csBefore, 'before');
437
+ const afterData = extractPseudoElementData(el, tag, cs, csAfter, 'after');
438
+ const formControl = extractFormControlData(el, tag, cs);
439
+ const svgMarkup = isSvg ? serializeSvgElement(el, rect) : null;
440
+ const imageData = isImage
441
+ ? extractImageData(el)
442
+ : isCanvas
443
+ ? extractCanvasImageData(el)
444
+ : null;
445
+
446
+ const children = isSvg || isImage || isCanvas || isTextContainer
447
+ ? []
448
+ : Array.from(el.childNodes)
449
+ .map((child) => getChildNode(child, el, cs, depth + 1))
450
+ .filter(Boolean);
451
+
452
+ return {
453
+ tag,
454
+ id: el.id || null,
455
+ classList: Array.from(el.classList),
456
+ text: rawText || null,
457
+ textRuns: textData?.runs || [],
458
+ isTextContainer,
459
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
460
+ computed: extractRelevantStyles(cs),
461
+ ...(formControl ? { formControl } : {}),
462
+ ...(svgMarkup ? { svgMarkup } : {}),
463
+ ...(imageData ? { imageData } : {}),
464
+ pseudo: {
465
+ before: beforeData,
466
+ after: afterData,
467
+ },
468
+ children,
469
+ };
470
+ }
471
+
472
+ function extractFormControlData(el, tag, computedStyles) {
473
+ if (tag !== 'input' && tag !== 'textarea') {
474
+ return null;
475
+ }
476
+
477
+ const type = tag === 'input'
478
+ ? String(el.getAttribute('type') || 'text').trim().toLowerCase()
479
+ : 'textarea';
480
+
481
+ if (tag === 'input' && !TEXT_INPUT_TYPES.has(type)) {
482
+ return null;
483
+ }
484
+
485
+ const placeholder = normalizeFormControlText(el.getAttribute('placeholder') || '', tag === 'textarea');
486
+ const value = type === 'password'
487
+ ? ''
488
+ : normalizeFormControlText(el.value || '', tag === 'textarea');
489
+
490
+ if (!placeholder && !value) {
491
+ return null;
492
+ }
493
+
494
+ const placeholderComputed = placeholder
495
+ ? extractPlaceholderStyles(el, computedStyles)
496
+ : null;
497
+
498
+ return {
499
+ type,
500
+ value,
501
+ placeholder,
502
+ ...(placeholderComputed ? { placeholderComputed } : {}),
503
+ };
504
+ }
505
+
506
+ function extractPlaceholderStyles(el, fallbackStyles) {
507
+ try {
508
+ const placeholderStyles = window.getComputedStyle(el, '::placeholder');
509
+ if (placeholderStyles) {
510
+ return extractRelevantStyles(placeholderStyles);
511
+ }
512
+ } catch (err) {}
513
+
514
+ return extractRelevantStyles(fallbackStyles);
515
+ }
516
+
517
+ function extractRelevantStyles(cs) {
518
+ return {
519
+ display: cs.display,
520
+ position: cs.position,
521
+ zIndex: cs.zIndex,
522
+ // Layout
523
+ flexDirection: cs.flexDirection,
524
+ justifyContent: cs.justifyContent,
525
+ alignItems: cs.alignItems,
526
+ flexWrap: cs.flexWrap,
527
+ flexGrow: cs.flexGrow,
528
+ flexShrink: cs.flexShrink,
529
+ flexBasis: cs.flexBasis,
530
+ gap: cs.gap,
531
+ columnGap: cs.columnGap,
532
+ rowGap: cs.rowGap,
533
+ gridTemplateColumns: cs.gridTemplateColumns,
534
+ gridTemplateRows: cs.gridTemplateRows,
535
+ gridRow: cs.gridRow,
536
+ gridColumn: cs.gridColumn,
537
+ // Sizing
538
+ width: cs.width,
539
+ height: cs.height,
540
+ minWidth: cs.minWidth,
541
+ maxWidth: cs.maxWidth,
542
+ minHeight: cs.minHeight,
543
+ // Spacing
544
+ paddingTop: cs.paddingTop,
545
+ paddingRight: cs.paddingRight,
546
+ paddingBottom: cs.paddingBottom,
547
+ paddingLeft: cs.paddingLeft,
548
+ marginTop: cs.marginTop,
549
+ marginRight: cs.marginRight,
550
+ marginBottom: cs.marginBottom,
551
+ marginLeft: cs.marginLeft,
552
+ // Visual
553
+ backgroundColor: cs.backgroundColor,
554
+ backgroundImage: cs.backgroundImage,
555
+ backgroundSize: cs.backgroundSize,
556
+ backgroundPosition: cs.backgroundPosition,
557
+ objectFit: cs.objectFit,
558
+ objectPosition: cs.objectPosition,
559
+ color: cs.color,
560
+ opacity: cs.opacity,
561
+ borderRadius: cs.borderRadius,
562
+ borderTopLeftRadius: cs.borderTopLeftRadius,
563
+ borderTopRightRadius: cs.borderTopRightRadius,
564
+ borderBottomRightRadius: cs.borderBottomRightRadius,
565
+ borderBottomLeftRadius: cs.borderBottomLeftRadius,
566
+ border: cs.border,
567
+ borderWidth: cs.borderWidth,
568
+ borderColor: cs.borderColor,
569
+ borderStyle: cs.borderStyle,
570
+ borderTopWidth: cs.borderTopWidth,
571
+ borderRightWidth: cs.borderRightWidth,
572
+ borderBottomWidth: cs.borderBottomWidth,
573
+ borderLeftWidth: cs.borderLeftWidth,
574
+ borderTopColor: cs.borderTopColor,
575
+ borderRightColor: cs.borderRightColor,
576
+ borderBottomColor: cs.borderBottomColor,
577
+ borderLeftColor: cs.borderLeftColor,
578
+ borderTopStyle: cs.borderTopStyle,
579
+ borderRightStyle: cs.borderRightStyle,
580
+ borderBottomStyle: cs.borderBottomStyle,
581
+ borderLeftStyle: cs.borderLeftStyle,
582
+ boxShadow: cs.boxShadow,
583
+ overflow: cs.overflow,
584
+ overflowX: cs.overflowX,
585
+ overflowY: cs.overflowY,
586
+ clipPath: cs.clipPath,
587
+ mixBlendMode: cs.mixBlendMode,
588
+ transform: cs.transform,
589
+ // Typography
590
+ fontFamily: cs.fontFamily,
591
+ fontSize: cs.fontSize,
592
+ fontWeight: cs.fontWeight,
593
+ fontStyle: cs.fontStyle,
594
+ lineHeight: cs.lineHeight,
595
+ letterSpacing: cs.letterSpacing,
596
+ textAlign: cs.textAlign,
597
+ textTransform: cs.textTransform,
598
+ whiteSpace: cs.whiteSpace,
599
+ textOverflow: cs.textOverflow,
600
+ textDecoration: cs.textDecoration,
601
+ webkitTextStrokeWidth: cs.webkitTextStrokeWidth,
602
+ webkitTextStrokeColor: cs.webkitTextStrokeColor,
603
+ // Positioning
604
+ top: cs.top,
605
+ right: cs.right,
606
+ bottom: cs.bottom,
607
+ left: cs.left,
608
+ inset: cs.inset,
609
+ // Content (for pseudo-elements)
610
+ content: cs.content,
611
+ };
612
+ }
613
+
614
+ function isVisuallyHiddenElement(cs) {
615
+ if (!cs) {
616
+ return true;
617
+ }
618
+
619
+ const opacity = parseFloat(cs.opacity);
620
+ return cs.display === 'none'
621
+ || cs.visibility === 'hidden'
622
+ || (Number.isFinite(opacity) && opacity <= 0);
623
+ }
624
+
625
+ function extractImageData(el) {
626
+ const src = String(el.currentSrc || el.src || el.getAttribute('src') || '').trim();
627
+ if (!src) {
628
+ return null;
629
+ }
630
+
631
+ return {
632
+ src,
633
+ alt: el.getAttribute('alt') || '',
634
+ naturalWidth: Number.isFinite(el.naturalWidth) ? el.naturalWidth : 0,
635
+ naturalHeight: Number.isFinite(el.naturalHeight) ? el.naturalHeight : 0,
636
+ };
637
+ }
638
+
639
+ function extractCanvasImageData(el) {
640
+ const width = Number(el.width) || 0;
641
+ const height = Number(el.height) || 0;
642
+ if (width <= 0 || height <= 0 || typeof el.toDataURL !== 'function') {
643
+ return null;
644
+ }
645
+
646
+ let src = String(el.__morphusCanvasCaptureSrc || '');
647
+ try {
648
+ src = src || el.toDataURL('image/png');
649
+ } catch (err) {
650
+ return null;
651
+ }
652
+
653
+ if (!src || src === 'data:,') {
654
+ return null;
655
+ }
656
+
657
+ return {
658
+ src,
659
+ alt: el.getAttribute('aria-label') || el.getAttribute('title') || '',
660
+ naturalWidth: width,
661
+ naturalHeight: height,
662
+ };
663
+ }
664
+
665
+ function extractTextData(el) {
666
+ const runs = [];
667
+ const pieces = [];
668
+ let lineIndex = 0;
669
+
670
+ function pushText(text, styleEl) {
671
+ const normalized = normalizeTextFragment(text);
672
+ if (!normalized) return;
673
+ pieces.push(normalized);
674
+ runs.push({
675
+ text: normalized,
676
+ lineIndex,
677
+ computed: extractTextRunStyles(window.getComputedStyle(styleEl)),
678
+ });
679
+ }
680
+
681
+ function walkText(node, styleEl) {
682
+ if (node.nodeType === Node.TEXT_NODE) {
683
+ pushText(node.textContent || '', styleEl);
684
+ return;
685
+ }
686
+
687
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
688
+
689
+ const element = node;
690
+ const tagName = element.tagName.toLowerCase();
691
+
692
+ if (tagName === 'br') {
693
+ pieces.push('\n');
694
+ lineIndex++;
695
+ return;
696
+ }
697
+
698
+ const nextStyleEl = element;
699
+ for (const child of element.childNodes) {
700
+ walkText(child, nextStyleEl);
701
+ }
702
+ }
703
+
704
+ for (const child of el.childNodes) {
705
+ walkText(child, el);
706
+ }
707
+
708
+ const text = pieces
709
+ .join('')
710
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
711
+ .replace(/[ \t]{2,}/g, ' ')
712
+ .trim();
713
+
714
+ return { text, runs };
715
+ }
716
+
717
+ function extractTextRunStyles(cs) {
718
+ return {
719
+ display: cs.display,
720
+ position: cs.position,
721
+ fontFamily: cs.fontFamily,
722
+ fontSize: cs.fontSize,
723
+ fontWeight: cs.fontWeight,
724
+ fontStyle: cs.fontStyle,
725
+ lineHeight: cs.lineHeight,
726
+ letterSpacing: cs.letterSpacing,
727
+ textAlign: cs.textAlign,
728
+ textTransform: cs.textTransform,
729
+ color: cs.color,
730
+ opacity: cs.opacity,
731
+ textDecoration: cs.textDecoration,
732
+ webkitTextStrokeWidth: cs.webkitTextStrokeWidth,
733
+ webkitTextStrokeColor: cs.webkitTextStrokeColor,
734
+ };
735
+ }
736
+
737
+ function serializeSvgElement(svgEl, rect) {
738
+ const clone = svgEl.cloneNode(true);
739
+
740
+ clone.setAttribute('xmlns', clone.getAttribute('xmlns') || 'http://www.w3.org/2000/svg');
741
+ clone.setAttribute('width', formatSvgNumber(rect.width));
742
+ clone.setAttribute('height', formatSvgNumber(rect.height));
743
+ clone.removeAttribute('opacity');
744
+ if (clone.style) {
745
+ clone.style.removeProperty('opacity');
746
+ }
747
+
748
+ inlineSvgPresentationStyles(svgEl, clone);
749
+
750
+ return new XMLSerializer().serializeToString(clone);
751
+ }
752
+
753
+ function inlineSvgPresentationStyles(sourceRoot, cloneRoot) {
754
+ const sourceElements = [sourceRoot].concat(Array.from(sourceRoot.querySelectorAll('*')));
755
+ const cloneElements = [cloneRoot].concat(Array.from(cloneRoot.querySelectorAll('*')));
756
+
757
+ for (let index = 0; index < sourceElements.length; index++) {
758
+ const sourceEl = sourceElements[index];
759
+ const cloneEl = cloneElements[index];
760
+ if (!sourceEl || !cloneEl) continue;
761
+
762
+ cloneEl.removeAttribute('data-morphus-animated');
763
+ const cs = window.getComputedStyle(sourceEl);
764
+ const isRoot = index === 0;
765
+
766
+ setSvgPresentationAttribute(cloneEl, 'fill', cs.fill);
767
+ setSvgPresentationAttribute(cloneEl, 'stroke', cs.stroke);
768
+ setSvgPresentationAttribute(cloneEl, 'stroke-width', cs.strokeWidth);
769
+ setSvgPresentationAttribute(cloneEl, 'stroke-linecap', cs.strokeLinecap);
770
+ setSvgPresentationAttribute(cloneEl, 'stroke-linejoin', cs.strokeLinejoin);
771
+ setSvgPresentationAttribute(cloneEl, 'stroke-miterlimit', cs.strokeMiterlimit);
772
+ setSvgPresentationAttribute(cloneEl, 'stroke-dasharray', cs.strokeDasharray);
773
+ setSvgPresentationAttribute(cloneEl, 'fill-rule', cs.fillRule);
774
+ setSvgPresentationAttribute(cloneEl, 'clip-rule', cs.clipRule);
775
+ setSvgPresentationAttribute(cloneEl, 'vector-effect', cs.vectorEffect);
776
+
777
+ if (!isRoot) {
778
+ setSvgPresentationAttribute(cloneEl, 'opacity', cs.opacity);
779
+ setSvgPresentationAttribute(cloneEl, 'fill-opacity', cs.fillOpacity);
780
+ setSvgPresentationAttribute(cloneEl, 'stroke-opacity', cs.strokeOpacity);
781
+ }
782
+ }
783
+ }
784
+
785
+ function setSvgPresentationAttribute(el, name, value) {
786
+ if (!isUsableSvgPresentationValue(value)) {
787
+ return;
788
+ }
789
+
790
+ el.setAttribute(name, normalizeSvgPresentationValue(value));
791
+ }
792
+
793
+ function isUsableSvgPresentationValue(value) {
794
+ if (value === undefined || value === null) {
795
+ return false;
796
+ }
797
+
798
+ const normalized = String(value).trim();
799
+ return normalized !== '' && normalized !== 'normal' && normalized !== 'auto';
800
+ }
801
+
802
+ function normalizeSvgPresentationValue(value) {
803
+ return String(value).trim();
804
+ }
805
+
806
+ function formatSvgNumber(value) {
807
+ const number = Number(value);
808
+ if (!Number.isFinite(number)) {
809
+ return '1';
810
+ }
811
+ return String(Math.max(Math.round(number * 1000) / 1000, 1));
812
+ }
813
+
814
  function getChildNode(child, parentEl, parentStyles, depth) {
815
  if (child.nodeType === Node.TEXT_NODE) {
816
+ return getDirectTextNode(child, parentEl, parentStyles);
817
  }
818
 
819
  if (child.nodeType === Node.ELEMENT_NODE) {
820
+ const node = getNode(child, depth);
821
+ if (!node) {
822
+ return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  }
 
824
 
825
+ if (parentEl && parentStyles && clippingEnabled(parentStyles)) {
826
+ const parentRect = parentEl.getBoundingClientRect();
827
+ if (isClippedOutsideParent(node.rect, parentRect, parentStyles)) {
828
+ return null;
829
+ }
 
 
 
830
  }
 
 
 
 
 
 
 
 
 
 
 
831
 
832
+ return node;
 
 
 
 
 
 
 
 
 
 
833
  }
834
 
835
  return null;
836
  }
837
 
838
+ function getDirectTextNode(textNode, parentEl, parentStyles) {
839
+ const normalizedText = normalizeTextFragment(textNode.textContent || '').trim();
840
+ if (!normalizedText) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
841
  return null;
842
  }
843
+
844
+ const range = document.createRange();
845
+ range.selectNodeContents(textNode);
846
+ const rect = range.getBoundingClientRect();
847
+ if (rect.width === 0 && rect.height === 0) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
848
  return null;
849
  }
850
 
851
+ if (parentEl && parentStyles && clippingEnabled(parentStyles)) {
852
+ const parentRect = parentEl.getBoundingClientRect();
853
+ if (isClippedOutsideParent(rect, parentRect, parentStyles)) {
854
+ return null;
 
 
 
 
 
 
 
 
855
  }
856
  }
857
 
858
+ const computed = extractRelevantStyles(parentStyles);
859
+ computed.display = 'inline';
860
+ computed.position = 'static';
861
+ computed.width = `${rect.width}px`;
862
+ computed.height = `${rect.height}px`;
863
+ computed.minWidth = '0px';
864
+ computed.minHeight = '0px';
865
+
866
+ return {
867
+ tag: 'span',
868
+ id: null,
869
+ classList: [],
870
+ text: normalizedText,
871
+ textRuns: [{
872
+ text: normalizedText,
873
+ lineIndex: 0,
874
+ computed: extractTextRunStyles(parentStyles),
875
+ }],
876
+ isTextContainer: true,
877
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
878
+ computed,
879
+ pseudo: {
880
+ before: null,
881
+ after: null,
882
+ },
883
+ children: [],
884
+ };
885
+ }
886
+
887
+ function normalizeTextFragment(text) {
888
+ return String(text || '')
889
+ .replace(/\r/g, '')
890
+ .replace(/\u00a0/g, ' ')
891
+ .replace(/\s+/g, ' ');
892
+ }
893
+
894
+ function isInlineTextChild(child) {
895
+ if (!child || child.nodeType !== Node.ELEMENT_NODE) return false;
896
+ const childTag = child.tagName.toLowerCase();
897
+ if (!INLINE_TAGS.has(childTag)) return false;
898
+
899
+ const childCs = window.getComputedStyle(child);
900
+ if (childCs.position !== 'static') return false;
901
+ if (childCs.display !== 'inline' && childCs.display !== 'contents') return false;
902
+ return !hasVisualBoxForStyles(childCs);
903
+ }
904
+
905
+ function hasVisualBoxForStyles(cs) {
906
+ return !isTransparentColor(cs.backgroundColor) ||
907
+ cs.backgroundImage !== 'none' ||
908
+ cs.borderStyle !== 'none' ||
909
+ parseFloat(cs.borderTopWidth) > 0 ||
910
+ parseFloat(cs.borderRightWidth) > 0 ||
911
+ parseFloat(cs.borderBottomWidth) > 0 ||
912
+ parseFloat(cs.borderLeftWidth) > 0 ||
913
+ parseFloat(cs.paddingTop) > 0 ||
914
+ parseFloat(cs.paddingRight) > 0 ||
915
+ parseFloat(cs.paddingBottom) > 0 ||
916
+ parseFloat(cs.paddingLeft) > 0 ||
917
+ cs.boxShadow !== 'none';
918
+ }
919
+
920
+ function hasRenderablePseudo(cs) {
921
+ if (!cs || cs.content === 'none' || cs.content === 'normal') {
922
+ return false;
923
+ }
924
+
925
+ return parseCssContent(cs.content) !== '' || hasSupportedPseudoVisual(cs);
926
+ }
927
+
928
+ function isVisuallyHiddenPseudo(cs, rect = null, parentRect = null, parentStyles = null) {
929
+ if (!cs) {
930
+ return true;
931
+ }
932
+
933
+ const opacity = parseFloat(cs.opacity);
934
+ if (cs.display === 'none' || cs.visibility === 'hidden' || (Number.isFinite(opacity) && opacity <= 0)) {
935
+ return true;
936
+ }
937
+
938
+ if (hasCollapsedTransform(cs.transform)) {
939
+ return true;
940
+ }
941
+
942
+ if (rect && isFullyClippedByClipPath(cs.clipPath, rect)) {
943
+ return true;
944
+ }
945
+
946
+ if (rect && parentRect && parentStyles && isClippedOutsideParent(rect, parentRect, parentStyles)) {
947
+ return true;
948
+ }
949
+
950
+ return false;
951
+ }
952
+
953
+ function hasCollapsedTransform(transformValue) {
954
+ if (!transformValue || transformValue === 'none') {
955
+ return false;
956
+ }
957
+
958
+ const scale = parseTransformScale(transformValue);
959
+ if (!scale) {
960
+ return false;
961
+ }
962
+
963
+ const tolerance = 0.001;
964
+ return scale.x <= tolerance || scale.y <= tolerance;
965
+ }
966
+
967
+ function parseTransformScale(transformValue) {
968
+ const value = String(transformValue).trim();
969
+ const matrixMatch = value.match(/^matrix\(([^)]+)\)$/i);
970
+ if (matrixMatch) {
971
+ const values = parseTransformNumbers(matrixMatch[1]);
972
+ if (values.length === 6) {
973
+ return {
974
+ x: Math.hypot(values[0], values[1]),
975
+ y: Math.hypot(values[2], values[3]),
976
+ };
977
+ }
978
+ }
979
+
980
+ const matrix3dMatch = value.match(/^matrix3d\(([^)]+)\)$/i);
981
+ if (matrix3dMatch) {
982
+ const values = parseTransformNumbers(matrix3dMatch[1]);
983
+ if (values.length === 16) {
984
+ return {
985
+ x: Math.hypot(values[0], values[1], values[2]),
986
+ y: Math.hypot(values[4], values[5], values[6]),
987
+ };
988
+ }
989
+ }
990
+
991
+ return parseScaleFunction(value);
992
+ }
993
+
994
+ function parseTransformNumbers(value) {
995
+ return String(value)
996
+ .split(',')
997
+ .map((part) => parseFloat(part.trim()))
998
+ .filter((number) => Number.isFinite(number));
999
+ }
1000
+
1001
+ function parseScaleFunction(value) {
1002
+ const scaleX = value.match(/scaleX\(\s*([-+]?\d*\.?\d+)/i);
1003
+ const scaleY = value.match(/scaleY\(\s*([-+]?\d*\.?\d+)/i);
1004
+ const scale = value.match(/scale\(\s*([-+]?\d*\.?\d+)(?:\s*,\s*([-+]?\d*\.?\d+))?/i);
1005
+
1006
+ if (scaleX || scaleY || scale) {
1007
+ const uniformScale = scale ? Math.abs(parseFloat(scale[1])) : 1;
1008
+ return {
1009
+ x: scaleX ? Math.abs(parseFloat(scaleX[1])) : uniformScale,
1010
+ y: scaleY ? Math.abs(parseFloat(scaleY[1])) : (scale?.[2] ? Math.abs(parseFloat(scale[2])) : uniformScale),
1011
+ };
1012
+ }
1013
+
1014
+ return null;
1015
+ }
1016
+
1017
+ function extractPseudoElementData(el, tag, parentStyles, pseudoStyles, pseudoType) {
1018
+ if (!hasRenderablePseudo(pseudoStyles)) {
1019
+ return null;
1020
+ }
1021
+
1022
+ const content = parseCssContent(pseudoStyles.content);
1023
+ if (!content && !hasSupportedPseudoVisual(pseudoStyles)) {
1024
+ return null;
1025
+ }
1026
+
1027
+ const parentRect = el.getBoundingClientRect();
1028
+ const rect = estimatePseudoTextRect(parentRect, parentStyles, pseudoStyles, pseudoType);
1029
+ const transformedRect = applyPseudoTransformRect(rect, pseudoStyles.transform);
1030
+ const finalRect = transformedRect || rect;
1031
+
1032
+ if (isVisuallyHiddenPseudo(pseudoStyles, finalRect, parentRect, parentStyles)) {
1033
+ return null;
1034
+ }
1035
+
1036
+ if (!content && (finalRect.width <= 0 || finalRect.height <= 0)) {
1037
+ return null;
1038
+ }
1039
+
1040
+ if (finalRect.width === 0 && finalRect.height === 0) {
1041
+ return null;
1042
+ }
1043
+
1044
+ return {
1045
+ name: `${buildPseudoName(el, tag)}::${pseudoType}`,
1046
+ type: content ? 'text' : 'box',
1047
+ content: content || null,
1048
+ rect: finalRect,
1049
+ fillColor: pseudoStyles.color,
1050
+ opacity: Number.isFinite(parseFloat(pseudoStyles.opacity)) ? parseFloat(pseudoStyles.opacity) : 1,
1051
+ position: pseudoStyles.position,
1052
+ zOrder: resolvePseudoZOrder(pseudoStyles, pseudoType),
1053
+ computed: extractRelevantStyles(pseudoStyles),
1054
+ };
1055
+ }
1056
+
1057
+ function resolvePseudoZOrder(pseudoStyles, pseudoType) {
1058
+ const zIndex = parseFloat(pseudoStyles.zIndex);
1059
+ if (Number.isFinite(zIndex)) {
1060
+ return zIndex < 0 ? 'bottom' : 'top';
1061
+ }
1062
+
1063
+ return pseudoType === 'before' ? 'bottom' : 'top';
1064
+ }
1065
+
1066
+ function hasSupportedPseudoVisual(cs) {
1067
+ return !isTransparentColor(cs.backgroundColor) ||
1068
+ String(cs.backgroundImage || '').includes('linear-gradient') ||
1069
+ cs.borderStyle !== 'none' ||
1070
+ parseFloat(cs.borderTopWidth) > 0 ||
1071
+ parseFloat(cs.borderRightWidth) > 0 ||
1072
+ parseFloat(cs.borderBottomWidth) > 0 ||
1073
+ parseFloat(cs.borderLeftWidth) > 0 ||
1074
+ cs.boxShadow !== 'none';
1075
+ }
1076
+
1077
+ function estimatePseudoTextRect(parentRect, parentStyles, pseudoStyles, pseudoType) {
1078
+ const width = parseCssPx(pseudoStyles.width);
1079
+ const height = parseCssPx(pseudoStyles.height) || parseCssPx(pseudoStyles.lineHeight) || parseCssPx(pseudoStyles.fontSize);
1080
+ const position = pseudoStyles.position;
1081
+
1082
+ if (position === 'absolute' || position === 'fixed') {
1083
+ return estimatePositionedPseudoRect(parentRect, pseudoStyles, width, height);
1084
+ }
1085
+
1086
+ if (parentStyles.display === 'flex' || parentStyles.display === 'inline-flex') {
1087
+ return estimateFlexPseudoRect(parentRect, parentStyles, width, height, pseudoType);
1088
+ }
1089
+
1090
+ return {
1091
+ x: pseudoType === 'before' ? parentRect.x : parentRect.right - width,
1092
+ y: parentRect.y + Math.max((parentRect.height - height) / 2, 0),
1093
+ width,
1094
+ height,
1095
+ };
1096
+ }
1097
+
1098
+ function applyPseudoTransformRect(rect, transformValue) {
1099
+ const matrix = parseCssTransformMatrix(transformValue);
1100
+ if (!matrix) {
1101
+ return rect;
1102
+ }
1103
+
1104
+ const points = [
1105
+ transformPoint(matrix, rect.x, rect.y),
1106
+ transformPoint(matrix, rect.x + rect.width, rect.y),
1107
+ transformPoint(matrix, rect.x, rect.y + rect.height),
1108
+ transformPoint(matrix, rect.x + rect.width, rect.y + rect.height),
1109
+ ];
1110
+
1111
+ const xs = points.map((point) => point.x);
1112
+ const ys = points.map((point) => point.y);
1113
+ const minX = Math.min(...xs);
1114
+ const maxX = Math.max(...xs);
1115
+ const minY = Math.min(...ys);
1116
+ const maxY = Math.max(...ys);
1117
+
1118
+ return {
1119
+ x: minX,
1120
+ y: minY,
1121
+ width: Math.max(maxX - minX, 0),
1122
+ height: Math.max(maxY - minY, 0),
1123
+ };
1124
+ }
1125
+
1126
+ function parseCssTransformMatrix(transformValue) {
1127
+ const value = String(transformValue || '').trim();
1128
+ if (!value || value === 'none') {
1129
+ return null;
1130
+ }
1131
+
1132
+ const matrixMatch = value.match(/^matrix\(([^)]+)\)$/i);
1133
+ if (matrixMatch) {
1134
+ const values = parseTransformNumbers(matrixMatch[1]);
1135
+ if (values.length === 6) {
1136
+ return {
1137
+ a: values[0],
1138
+ b: values[1],
1139
+ c: values[2],
1140
+ d: values[3],
1141
+ e: values[4],
1142
+ f: values[5],
1143
+ };
1144
+ }
1145
+ }
1146
+
1147
+ const matrix3dMatch = value.match(/^matrix3d\(([^)]+)\)$/i);
1148
+ if (matrix3dMatch) {
1149
+ const values = parseTransformNumbers(matrix3dMatch[1]);
1150
+ if (values.length === 16) {
1151
+ return {
1152
+ a: values[0],
1153
+ b: values[1],
1154
+ c: values[4],
1155
+ d: values[5],
1156
+ e: values[12],
1157
+ f: values[13],
1158
+ };
1159
+ }
1160
+ }
1161
+
1162
+ return null;
1163
+ }
1164
+
1165
+ function transformPoint(matrix, x, y) {
1166
+ return {
1167
+ x: (matrix.a * x) + (matrix.c * y) + matrix.e,
1168
+ y: (matrix.b * x) + (matrix.d * y) + matrix.f,
1169
+ };
1170
+ }
1171
+
1172
+ function isFullyClippedByClipPath(clipPath, rect) {
1173
+ const value = String(clipPath || '').trim();
1174
+ if (!value || value === 'none') {
1175
+ return false;
1176
+ }
1177
+
1178
+ const insetMatch = value.match(/^inset\((.+)\)$/i);
1179
+ if (!insetMatch) {
1180
+ return false;
1181
+ }
1182
+
1183
+ const parts = splitInsetTokens(insetMatch[1]);
1184
+ const [topToken, rightToken, bottomToken, leftToken] = normalizeInsetTokens(parts);
1185
+ const top = resolveInsetValue(topToken, rect.height);
1186
+ const right = resolveInsetValue(rightToken, rect.width);
1187
+ const bottom = resolveInsetValue(bottomToken, rect.height);
1188
+ const left = resolveInsetValue(leftToken, rect.width);
1189
+
1190
+ return rect.width - left - right <= 0 || rect.height - top - bottom <= 0;
1191
+ }
1192
+
1193
+ function splitInsetTokens(value) {
1194
+ return String(value)
1195
+ .split(/\s+round\s+/i)[0]
1196
+ .trim()
1197
+ .split(/\s+/)
1198
+ .filter(Boolean);
1199
+ }
1200
+
1201
+ function normalizeInsetTokens(tokens) {
1202
+ if (tokens.length === 1) {
1203
+ return [tokens[0], tokens[0], tokens[0], tokens[0]];
1204
+ }
1205
+ if (tokens.length === 2) {
1206
+ return [tokens[0], tokens[1], tokens[0], tokens[1]];
1207
+ }
1208
+ if (tokens.length === 3) {
1209
+ return [tokens[0], tokens[1], tokens[2], tokens[1]];
1210
+ }
1211
+ return [tokens[0], tokens[1], tokens[2], tokens[3]];
1212
+ }
1213
+
1214
+ function resolveInsetValue(token, size) {
1215
+ const value = String(token || '').trim();
1216
+ if (!value || value === 'auto') {
1217
+ return 0;
1218
+ }
1219
+ if (value.endsWith('%')) {
1220
+ const ratio = parseFloat(value);
1221
+ return Number.isFinite(ratio) ? (ratio / 100) * size : 0;
1222
+ }
1223
+ const parsed = parseFloat(value);
1224
+ return Number.isFinite(parsed) ? parsed : 0;
1225
+ }
1226
+
1227
+ function isClippedOutsideParent(rect, parentRect, parentStyles) {
1228
+ if (!clippingEnabled(parentStyles)) {
1229
+ return false;
1230
+ }
1231
+
1232
+ const intersectionWidth = Math.min(rect.x + rect.width, parentRect.x + parentRect.width) - Math.max(rect.x, parentRect.x);
1233
+ const intersectionHeight = Math.min(rect.y + rect.height, parentRect.y + parentRect.height) - Math.max(rect.y, parentRect.y);
1234
+
1235
+ return intersectionWidth <= 0.5 || intersectionHeight <= 0.5;
1236
+ }
1237
+
1238
+ function clippingEnabled(parentStyles) {
1239
+ if (!parentStyles) {
1240
+ return false;
1241
+ }
1242
+
1243
+ return ['overflow', 'overflowX', 'overflowY'].some((prop) => {
1244
+ const value = String(parentStyles[prop] || '').toLowerCase();
1245
+ return value === 'hidden' || value === 'clip' || value === 'scroll' || value === 'auto';
1246
+ });
1247
+ }
1248
+
1249
+ function estimatePositionedPseudoRect(parentRect, pseudoStyles, width, height) {
1250
+ const left = pseudoStyles.left !== 'auto' ? parseCssPx(pseudoStyles.left) : null;
1251
+ const right = pseudoStyles.right !== 'auto' ? parseCssPx(pseudoStyles.right) : null;
1252
+ const top = pseudoStyles.top !== 'auto' ? parseCssPx(pseudoStyles.top) : null;
1253
+ const bottom = pseudoStyles.bottom !== 'auto' ? parseCssPx(pseudoStyles.bottom) : null;
1254
+
1255
+ return {
1256
+ x: parentRect.x + (left !== null ? left : parentRect.width - width - (right || 0)),
1257
+ y: parentRect.y + (top !== null ? top : parentRect.height - height - (bottom || 0)),
1258
+ width,
1259
+ height,
1260
+ };
1261
+ }
1262
+
1263
+ function estimateFlexPseudoRect(parentRect, parentStyles, width, height, pseudoType) {
1264
+ const isRow = parentStyles.flexDirection !== 'column' && parentStyles.flexDirection !== 'column-reverse';
1265
+ const isReverse = parentStyles.flexDirection === 'row-reverse' || parentStyles.flexDirection === 'column-reverse';
1266
+ const isEnd = (pseudoType === 'after') !== isReverse;
1267
+
1268
+ if (isRow) {
1269
+ return {
1270
+ x: isEnd ? parentRect.right - width : parentRect.x,
1271
+ y: alignCrossAxis(parentRect.y, parentRect.height, height, parentStyles.alignItems),
1272
+ width,
1273
+ height,
1274
+ };
1275
+ }
1276
+
1277
+ return {
1278
+ x: alignCrossAxis(parentRect.x, parentRect.width, width, parentStyles.alignItems),
1279
+ y: isEnd ? parentRect.bottom - height : parentRect.y,
1280
+ width,
1281
+ height,
1282
+ };
1283
+ }
1284
+
1285
+ function alignCrossAxis(start, parentSize, childSize, alignItems) {
1286
+ if (alignItems === 'center') {
1287
+ return start + Math.max((parentSize - childSize) / 2, 0);
1288
+ }
1289
+ if (alignItems === 'flex-end') {
1290
+ return start + Math.max(parentSize - childSize, 0);
1291
+ }
1292
+ return start;
1293
+ }
1294
+
1295
+ function parseCssContent(value) {
1296
+ if (!value || value === 'none' || value === 'normal') return '';
1297
+ const trimmed = String(value).trim();
1298
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
1299
+ return trimmed.slice(1, -1)
1300
+ .replace(/\\"/g, '"')
1301
+ .replace(/\\'/g, "'");
1302
+ }
1303
+ return trimmed;
1304
+ }
1305
+
1306
+ function parseCssPx(value) {
1307
+ if (!value || value === 'auto' || value === 'normal' || value === 'none') return 0;
1308
+ const parsed = parseFloat(value);
1309
+ return Number.isFinite(parsed) ? parsed : 0;
1310
+ }
1311
+
1312
+ function buildPseudoName(el, tag) {
1313
+ const classPart = Array.from(el.classList || []).slice(0, 2).join('.');
1314
+ return classPart ? `${tag}.${classPart}` : tag;
1315
+ }
1316
+
1317
+ function canCollapseToTextContainer(el, tag, cs, hasOnlyInlineTextChildren) {
1318
+ const hasElementChildren = el.children.length > 0;
1319
+ if (!hasElementChildren) {
1320
+ return true;
1321
+ }
1322
+
1323
+ if (!hasOnlyInlineTextChildren) {
1324
+ return false;
1325
+ }
1326
+
1327
+ return TEXT_TAGS.has(tag) || tag === 'div';
1328
+ }
1329
+
1330
+ function normalizeTextContent(value) {
1331
+ return String(value || '')
1332
+ .replace(/\r/g, '')
1333
+ .replace(/\u00a0/g, ' ')
1334
+ .replace(/[ \t]+\n/g, '\n')
1335
+ .replace(/\n[ \t]+/g, '\n')
1336
+ .replace(/[ \t]{2,}/g, ' ')
1337
+ .trim();
1338
+ }
1339
+
1340
+ function normalizeFormControlText(value, preserveLineBreaks = false) {
1341
+ const text = String(value || '').replace(/\r/g, '');
1342
+ if (preserveLineBreaks) {
1343
+ return text.trim();
1344
+ }
1345
+ return normalizeTextContent(text);
1346
+ }
1347
+
1348
+ function isTransparentColor(value) {
1349
+ return !value || value === 'transparent' || value === 'none' || value === 'rgba(0, 0, 0, 0)';
1350
+ }
1351
+
1352
+ return getNode(document.body);
1353
+ }
src/figma/css-to-figma.js CHANGED
@@ -1,452 +1,498 @@
1
- /**
2
- * src/figma/css-to-figma.js
3
- * Deterministic CSS property → Figma property mapper.
4
- * This is the core 1:1 mapping layer.
5
- *
6
- * All functions here take CSS computed style values
7
- * and return Figma Plugin API property objects.
8
- */
9
-
10
- import { cssColorToFigma, solidPaint } from '../utils/color.js';
11
- import {
12
- parsePx,
13
- letterSpacingToPx,
14
- lineHeightToFigma,
15
- WEIGHT_MAP,
16
- TEXT_ALIGN_MAP,
17
- TEXT_CASE_MAP,
18
- JUSTIFY_MAP,
19
- ALIGN_MAP,
20
- } from '../utils/units.js';
21
-
22
- function isTransparentCssColor(value) {
23
- if (!value || value === 'transparent' || value === 'none') {
24
- return true;
25
- }
26
  return cssColorToFigma(value).a === 0;
27
  }
28
 
29
- // ─── LAYOUT ──────────────────────────────────────────────────────────────────
30
-
31
- /**
32
- * display: flex → Figma Auto Layout
33
- */
34
- export function mapFlexLayout(computed) {
35
- const isRow = computed.flexDirection !== 'column' && computed.flexDirection !== 'column-reverse';
36
- return {
37
- layoutMode: isRow ? 'HORIZONTAL' : 'VERTICAL',
38
- primaryAxisAlignItems: JUSTIFY_MAP[computed.justifyContent] ?? 'MIN',
39
- counterAxisAlignItems: ALIGN_MAP[computed.alignItems] ?? 'MIN',
40
- itemSpacing: parsePx(computed.gap || computed.columnGap || computed.rowGap),
41
- };
42
- }
43
-
44
- /**
45
- * padding → Figma frame padding
46
- */
47
- export function mapPadding(computed) {
48
- return {
49
- paddingTop: parsePx(computed.paddingTop),
50
- paddingRight: parsePx(computed.paddingRight),
51
- paddingBottom: parsePx(computed.paddingBottom),
52
- paddingLeft: parsePx(computed.paddingLeft),
53
- };
54
- }
55
-
56
- /**
57
- * overflow → Figma clipsContent
58
- */
59
- export function mapOverflow(computed) {
60
- return {
61
- clipsContent: computed.overflow === 'hidden' || computed.overflowX === 'hidden' || computed.overflowY === 'hidden',
62
- };
63
  }
64
 
65
- /**
66
- * border-radius → Figma cornerRadius
67
- */
68
- export function mapBorderRadius(computed, rect = { width: 0, height: 0 }) {
69
- const tl = parseRadiusValue(computed.borderTopLeftRadius, rect);
70
- const tr = parseRadiusValue(computed.borderTopRightRadius, rect);
71
- const br = parseRadiusValue(computed.borderBottomRightRadius, rect);
72
- const bl = parseRadiusValue(computed.borderBottomLeftRadius, rect);
73
-
74
- if (tl === tr && tr === br && br === bl) {
75
- return { cornerRadius: tl };
76
  }
77
- return {
78
- topLeftRadius: tl,
79
- topRightRadius: tr,
80
- bottomRightRadius: br,
81
- bottomLeftRadius: bl,
82
- };
83
- }
84
 
85
- function parseRadiusValue(value, rect) {
86
- if (!value || value === 'none' || value === 'auto') return 0;
87
- if (typeof value === 'string' && value.endsWith('%')) {
88
- const percent = parseFloat(value);
89
- if (Number.isFinite(percent)) {
90
- return (Math.min(rect.width || 0, rect.height || 0) * percent) / 100;
91
- }
92
- }
93
- return parsePx(value);
94
  }
95
 
96
- // ─── VISUAL / FILLS ───────────────────────────────────────────────────────────
97
-
98
- /**
99
- * background-color → Figma solid fill
100
- */
101
- export function mapBackgroundColor(computed) {
102
- const color = computed.backgroundColor;
103
- if (isTransparentCssColor(color)) return [];
104
- return [solidPaint(color)];
105
  }
106
 
107
  /**
108
- * Parse CSS linear-gradient Figma GRADIENT_LINEAR paint.
109
- * Handles: linear-gradient(to bottom, ...) and linear-gradient(180deg, ...)
110
  */
111
- export function parseLinearGradient(cssGradient) {
112
- const gradientTransform = linearGradientTransform(cssGradient);
113
-
114
- // Extract color stops (simplified — handles rgba and hex)
115
- const stops = extractGradientStops(cssGradient);
116
 
117
- return {
118
- type: 'GRADIENT_LINEAR',
119
- gradientTransform,
120
- gradientStops: stops,
121
- };
122
- }
123
-
124
- export function parseLinearGradientLayers(cssBackgroundImage) {
125
- return splitCssLayers(cssBackgroundImage)
126
- .filter((layer) => /^linear-gradient\(/i.test(layer.trim()))
127
- .map((layer) => parseLinearGradient(layer));
128
- }
129
-
130
- function linearGradientTransform(cssGradient) {
131
- const angle = parseLinearGradientAngle(cssGradient);
132
- const rad = ((angle - 90) * Math.PI) / 180;
133
- const dx = normalizeZero(Math.cos(rad));
134
- const dy = normalizeZero(Math.sin(rad));
135
-
136
- return [
137
- [dx, dy, normalizeZero(0.5 - dx / 2 - dy / 2)],
138
- [normalizeZero(-dy), dx, normalizeZero(0.5 + dy / 2 - dx / 2)],
139
- ];
140
- }
141
-
142
- function normalizeZero(value) {
143
- if (Math.abs(value) < 1e-12) return 0;
144
- if (Math.abs(value - 1) < 1e-12) return 1;
145
- if (Math.abs(value + 1) < 1e-12) return -1;
146
- return value;
147
- }
148
-
149
- function parseLinearGradientAngle(cssGradient) {
150
- const directionMatch = cssGradient.match(/linear-gradient\(\s*to\s+([a-z\s]+?)(?:,|\))/i);
151
- if (directionMatch) {
152
- const direction = directionMatch[1].trim().toLowerCase();
153
- if (direction === 'right') return 90;
154
- if (direction === 'left') return 270;
155
- if (direction === 'bottom') return 180;
156
- if (direction === 'top') return 0;
157
- if (direction === 'bottom right' || direction === 'right bottom') return 135;
158
- if (direction === 'bottom left' || direction === 'left bottom') return 225;
159
- if (direction === 'top right' || direction === 'right top') return 45;
160
- if (direction === 'top left' || direction === 'left top') return 315;
161
  }
162
 
163
- const degMatch = cssGradient.match(/linear-gradient\(\s*(-?[\d.]+)deg/i);
164
- if (degMatch) {
165
- const angle = parseFloat(degMatch[1]);
166
- if (Number.isFinite(angle)) return angle;
167
  }
168
 
169
- return 180;
170
  }
171
-
172
- function extractGradientStops(css) {
173
- const stopRegex = /(rgba?\([^)]+\)|#[0-9a-f]{3,8}|transparent)\s*([\d.]+%)?/gi;
174
- const stops = [];
175
- let match;
176
- let index = 0;
177
-
178
- while ((match = stopRegex.exec(css)) !== null) {
179
- const color = cssColorToFigma(match[1]);
180
- const position = match[2] ? parseFloat(match[2]) / 100 : index === 0 ? 0 : 1;
181
- stops.push({ color, position });
182
- index++;
183
- }
184
-
185
- return stops.length > 0 ? stops : [
186
- { color: { r: 0, g: 0, b: 0, a: 0 }, position: 0 },
187
- { color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 },
188
- ];
189
- }
190
-
191
- function splitCssLayers(css) {
192
- const source = String(css || '');
193
- const layers = [];
194
- let current = '';
195
- let depth = 0;
196
- let quote = null;
197
-
198
- for (let index = 0; index < source.length; index++) {
199
- const char = source[index];
200
-
201
- if (quote) {
202
- current += char;
203
- if (char === quote && source[index - 1] !== '\\') {
204
- quote = null;
205
- }
206
- continue;
207
- }
208
-
209
- if (char === '"' || char === "'") {
210
- quote = char;
211
- current += char;
212
- continue;
213
- }
214
-
215
- if (char === '(') {
216
- depth++;
217
- current += char;
218
- continue;
219
- }
220
-
221
- if (char === ')') {
222
- depth = Math.max(depth - 1, 0);
223
- current += char;
224
- continue;
225
- }
226
-
227
- if (char === ',' && depth === 0) {
228
- if (current.trim()) {
229
- layers.push(current.trim());
230
- }
231
- current = '';
232
- continue;
233
- }
234
-
235
- current += char;
236
- }
237
-
238
- if (current.trim()) {
239
- layers.push(current.trim());
240
- }
241
-
242
- return layers;
243
- }
244
-
245
- // ─── BORDERS / STROKES ────────────────────────────────────────────────────────
246
-
247
- /**
248
- * border → Figma strokes
249
- */
250
- export function mapBorder(computed) {
251
- const sides = getBorderSides(computed);
252
- const visibleSides = Object.values(sides).filter((side) => isRenderableBorderSide(side));
253
- if (visibleSides.length === 0) return {};
254
-
255
- const color = cssColorToFigma(visibleSides[0].color);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  const result = {
257
- strokes: [{
258
- type: 'SOLID',
259
- color: { r: color.r, g: color.g, b: color.b },
260
- opacity: color.a,
261
- }],
262
- strokeWeight: visibleSides[0].width,
263
- strokeAlign: 'INSIDE', // CSS border-box behavior
264
- };
265
-
266
- if (!hasUniformBorder(sides)) {
267
- result.strokeTopWeight = isRenderableBorderSide(sides.top) ? sides.top.width : 0;
268
- result.strokeRightWeight = isRenderableBorderSide(sides.right) ? sides.right.width : 0;
269
- result.strokeBottomWeight = isRenderableBorderSide(sides.bottom) ? sides.bottom.width : 0;
270
- result.strokeLeftWeight = isRenderableBorderSide(sides.left) ? sides.left.width : 0;
271
- }
272
-
273
- return result;
274
- }
275
-
276
- function getBorderSides(computed) {
277
- return {
278
- top: getBorderSide(computed, 'Top', 0),
279
- right: getBorderSide(computed, 'Right', 1),
280
- bottom: getBorderSide(computed, 'Bottom', 2),
281
- left: getBorderSide(computed, 'Left', 3),
282
- };
283
- }
284
-
285
- function getBorderSide(computed, sideName, shorthandIndex) {
286
- const lowerSide = sideName.toLowerCase();
287
- return {
288
- width: parsePx(computed[`border${sideName}Width`] ?? getCssBoxValue(computed.borderWidth, shorthandIndex)),
289
- style: computed[`border${sideName}Style`] ?? getCssBoxValue(computed.borderStyle, shorthandIndex) ?? 'none',
290
- color: computed[`border${sideName}Color`] ?? getCssBoxValue(computed.borderColor, shorthandIndex) ?? computed.color ?? '#000',
291
- side: lowerSide,
292
- };
293
- }
294
-
295
- function isRenderableBorderSide(side) {
296
- return side.width > 0 && side.style !== 'none' && side.style !== 'hidden' && cssColorToFigma(side.color).a > 0;
297
- }
298
-
299
- function hasUniformBorder(sides) {
300
- const values = Object.values(sides);
301
- const first = values[0];
302
- return values.every((side) =>
303
- isRenderableBorderSide(side) &&
304
- side.width === first.width &&
305
- side.style === first.style &&
306
- normalizeCssValue(side.color) === normalizeCssValue(first.color)
307
- );
308
- }
309
-
310
- function getCssBoxValue(value, index) {
311
- const parts = splitCssWhitespaceList(value);
312
- if (parts.length === 0) return null;
313
- if (parts.length === 1) return parts[0];
314
- if (parts.length === 2) return index === 0 || index === 2 ? parts[0] : parts[1];
315
- if (parts.length === 3) return index === 0 ? parts[0] : index === 2 ? parts[2] : parts[1];
316
- return parts[index] ?? null;
317
- }
318
-
319
- function splitCssWhitespaceList(value) {
320
- const source = String(value || '').trim();
321
- if (!source) return [];
322
-
323
- const parts = [];
324
- let current = '';
325
- let depth = 0;
326
-
327
- for (let index = 0; index < source.length; index++) {
328
- const char = source[index];
329
- if (char === '(') depth++;
330
- if (char === ')') depth = Math.max(depth - 1, 0);
331
-
332
- if (/\s/.test(char) && depth === 0) {
333
- if (current.trim()) {
334
- parts.push(current.trim());
335
- }
336
- current = '';
337
- continue;
338
- }
339
-
340
- current += char;
341
- }
342
-
343
- if (current.trim()) {
344
- parts.push(current.trim());
345
- }
346
-
347
- return parts;
348
- }
349
-
350
- function normalizeCssValue(value) {
351
- return String(value || '').replace(/\s+/g, '').toLowerCase();
352
- }
353
-
354
- // ─── EFFECTS ─────────────────────────────────────────────────────────────────
355
-
356
- /**
357
- * box-shadow → Figma DROP_SHADOW effect
358
- * Handles: "0 0 30px 10px rgba(201,168,76,0.3)"
359
- */
360
- export function mapBoxShadow(computed) {
361
- if (!computed.boxShadow || computed.boxShadow === 'none') return [];
362
-
363
- const parts = computed.boxShadow.match(
364
- /(?:(rgba?\([^)]+\)|#[0-9a-f]{3,8})\s+)?(-?[\d.]+px)\s+(-?[\d.]+px)\s+([\d.]+px)\s*([\d.]+px)?(?:\s+(rgba?\([^)]+\)|#[0-9a-f]{3,8}))?/i
365
- );
366
- if (!parts) return [];
367
-
368
- const [, leadingColor, x, y, blur, spread = '0px', trailingColor] = parts;
369
- const colorStr = leadingColor || trailingColor;
370
- if (!colorStr) return [];
371
- const color = cssColorToFigma(colorStr);
372
-
373
- return [{
374
- type: 'DROP_SHADOW',
375
- color: { r: color.r, g: color.g, b: color.b, a: color.a },
376
- offset: { x: parsePx(x), y: parsePx(y) },
377
- radius: parsePx(blur),
378
- spread: parsePx(spread),
379
- visible: true,
380
- blendMode: 'NORMAL',
381
- }];
382
- }
383
-
384
- // ─── TYPOGRAPHY ───────────────────────────────────────────────────────────────
385
-
386
- /**
387
- * CSS text properties → Figma text node properties
388
- */
389
- export function mapTypography(computed, fontMap) {
390
- const familyRaw = computed.fontFamily?.split(',')[0].replace(/['"]/g, '').trim() ?? 'Inter';
391
- const weight = computed.fontWeight ?? '400';
392
- const isItalic = computed.fontStyle === 'italic';
393
- const fontKey = `${computed.fontFamily}|${weight}|${isItalic ? 'italic' : 'normal'}`;
394
-
395
- const font = fontMap?.[fontKey] ?? { family: familyRaw, style: 'Regular' };
396
- const fontSize = parsePx(computed.fontSize) || 16;
397
-
398
- return {
399
  fontName: font,
400
  fontSize,
401
  lineHeight: lineHeightToFigma(computed.lineHeight, computed.fontSize),
402
  letterSpacing: {
403
  value: letterSpacingToPx(computed.letterSpacing, computed.fontSize),
404
- unit: 'PIXELS',
405
- },
406
- textAlignHorizontal: TEXT_ALIGN_MAP[computed.textAlign] ?? 'LEFT',
407
- textCase: TEXT_CASE_MAP[computed.textTransform] ?? 'ORIGINAL',
408
- // -webkit-text-stroke → outline text (color: transparent + stroke)
409
  fills: isTransparentCssColor(computed.color)
410
  ? []
411
  : [solidPaint(computed.color)],
412
  };
413
- }
414
-
415
- /**
416
- * Map -webkit-text-stroke to Figma strokes on a text node.
417
- */
418
- export function mapTextStroke(computed) {
419
- // CSS doesn't expose webkit-text-stroke in getComputedStyle reliably,
420
- // but if fill is transparent we know it's outline text
421
- if (isTransparentCssColor(computed.color) && parsePx(computed.webkitTextStrokeWidth) > 0) {
422
- const width = parsePx(computed.webkitTextStrokeWidth);
423
- const color = cssColorToFigma(computed.webkitTextStrokeColor ?? '#000');
424
- return {
425
- strokes: [{
426
- type: 'SOLID',
427
- color: { r: color.r, g: color.g, b: color.b },
428
- opacity: color.a,
429
- }],
430
- strokeWeight: width,
431
- strokeAlign: 'OUTSIDE',
432
- };
433
- }
434
- return {};
435
- }
436
 
437
- // ─── POSITIONING ─────────────────────────────────────────────────────────────
438
-
439
- /**
440
- * position: absolute → Figma absolute positioning
441
- */
442
- export function mapPositioning(computed, rect, parentRect) {
443
- if (computed.position !== 'absolute' && computed.position !== 'fixed') {
444
- return {};
445
  }
446
 
447
- return {
448
- layoutPositioning: 'ABSOLUTE',
449
- x: rect.x - (parentRect?.x ?? 0),
450
- y: rect.y - (parentRect?.y ?? 0),
451
- };
452
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * src/figma/css-to-figma.js
3
+ * Deterministic CSS property → Figma property mapper.
4
+ * This is the core 1:1 mapping layer.
5
+ *
6
+ * All functions here take CSS computed style values
7
+ * and return Figma Plugin API property objects.
8
+ */
9
+
10
+ import { cssColorToFigma, solidPaint } from '../utils/color.js';
11
+ import {
12
+ parsePx,
13
+ letterSpacingToPx,
14
+ lineHeightToFigma,
15
+ WEIGHT_MAP,
16
+ TEXT_ALIGN_MAP,
17
+ TEXT_CASE_MAP,
18
+ JUSTIFY_MAP,
19
+ ALIGN_MAP,
20
+ } from '../utils/units.js';
21
+
22
+ function isTransparentCssColor(value) {
23
+ if (!value || value === 'transparent' || value === 'none') {
24
+ return true;
25
+ }
26
  return cssColorToFigma(value).a === 0;
27
  }
28
 
29
+ function meaningfulTextOverflow(value) {
30
+ const normalized = String(value || '').trim().toLowerCase();
31
+ return normalized && normalized !== 'clip' && normalized !== 'none' ? normalized : '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
 
34
+ function overflowClipsInlineContent(computed) {
35
+ if (!computed) {
36
+ return false;
 
 
 
 
 
 
 
 
37
  }
 
 
 
 
 
 
 
38
 
39
+ return ['overflow', 'overflowX'].some((prop) => {
40
+ const value = String(computed[prop] || '').trim().toLowerCase();
41
+ return value.split(/\s+/).some((part) => part === 'hidden' || part === 'clip' || part === 'scroll' || part === 'auto');
42
+ });
 
 
 
 
 
43
  }
44
 
45
+ function isSingleLineWhiteSpace(value) {
46
+ const normalized = String(value || '').trim().toLowerCase();
47
+ return normalized.includes('nowrap') || normalized === 'pre';
 
 
 
 
 
 
48
  }
49
 
50
  /**
51
+ * CSS ellipsis requires text-overflow, non-wrapping inline text,
52
+ * and clipping on either the text box or the parent cell/container.
53
  */
54
+ export function shouldTruncateText(computed = {}, parentComputed = null) {
55
+ const textOverflow = meaningfulTextOverflow(computed?.textOverflow)
56
+ || meaningfulTextOverflow(parentComputed?.textOverflow);
 
 
57
 
58
+ if (!textOverflow.includes('ellipsis')) {
59
+ return false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
 
62
+ if (!isSingleLineWhiteSpace(computed?.whiteSpace) && !isSingleLineWhiteSpace(parentComputed?.whiteSpace)) {
63
+ return false;
 
 
64
  }
65
 
66
+ return overflowClipsInlineContent(computed) || overflowClipsInlineContent(parentComputed);
67
  }
68
+
69
+ // ─── LAYOUT ──────────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * display: flex → Figma Auto Layout
73
+ */
74
+ export function mapFlexLayout(computed) {
75
+ const isRow = computed.flexDirection !== 'column' && computed.flexDirection !== 'column-reverse';
76
+ return {
77
+ layoutMode: isRow ? 'HORIZONTAL' : 'VERTICAL',
78
+ primaryAxisAlignItems: JUSTIFY_MAP[computed.justifyContent] ?? 'MIN',
79
+ counterAxisAlignItems: ALIGN_MAP[computed.alignItems] ?? 'MIN',
80
+ itemSpacing: parsePx(computed.gap || computed.columnGap || computed.rowGap),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * padding → Figma frame padding
86
+ */
87
+ export function mapPadding(computed) {
88
+ return {
89
+ paddingTop: parsePx(computed.paddingTop),
90
+ paddingRight: parsePx(computed.paddingRight),
91
+ paddingBottom: parsePx(computed.paddingBottom),
92
+ paddingLeft: parsePx(computed.paddingLeft),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * overflow → Figma clipsContent
98
+ */
99
+ export function mapOverflow(computed) {
100
+ return {
101
+ clipsContent: computed.overflow === 'hidden' || computed.overflowX === 'hidden' || computed.overflowY === 'hidden',
102
+ };
103
+ }
104
+
105
+ /**
106
+ * border-radius Figma cornerRadius
107
+ */
108
+ export function mapBorderRadius(computed, rect = { width: 0, height: 0 }) {
109
+ const tl = parseRadiusValue(computed.borderTopLeftRadius, rect);
110
+ const tr = parseRadiusValue(computed.borderTopRightRadius, rect);
111
+ const br = parseRadiusValue(computed.borderBottomRightRadius, rect);
112
+ const bl = parseRadiusValue(computed.borderBottomLeftRadius, rect);
113
+
114
+ if (tl === tr && tr === br && br === bl) {
115
+ return { cornerRadius: tl };
116
+ }
117
+ return {
118
+ topLeftRadius: tl,
119
+ topRightRadius: tr,
120
+ bottomRightRadius: br,
121
+ bottomLeftRadius: bl,
122
+ };
123
+ }
124
+
125
+ function parseRadiusValue(value, rect) {
126
+ if (!value || value === 'none' || value === 'auto') return 0;
127
+ if (typeof value === 'string' && value.endsWith('%')) {
128
+ const percent = parseFloat(value);
129
+ if (Number.isFinite(percent)) {
130
+ return (Math.min(rect.width || 0, rect.height || 0) * percent) / 100;
131
+ }
132
+ }
133
+ return parsePx(value);
134
+ }
135
+
136
+ // ─── VISUAL / FILLS ───────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * background-color → Figma solid fill
140
+ */
141
+ export function mapBackgroundColor(computed) {
142
+ const color = computed.backgroundColor;
143
+ if (isTransparentCssColor(color)) return [];
144
+ return [solidPaint(color)];
145
+ }
146
+
147
+ /**
148
+ * Parse CSS linear-gradient → Figma GRADIENT_LINEAR paint.
149
+ * Handles: linear-gradient(to bottom, ...) and linear-gradient(180deg, ...)
150
+ */
151
+ export function parseLinearGradient(cssGradient) {
152
+ const gradientTransform = linearGradientTransform(cssGradient);
153
+
154
+ // Extract color stops (simplified — handles rgba and hex)
155
+ const stops = extractGradientStops(cssGradient);
156
+
157
+ return {
158
+ type: 'GRADIENT_LINEAR',
159
+ gradientTransform,
160
+ gradientStops: stops,
161
+ };
162
+ }
163
+
164
+ export function parseLinearGradientLayers(cssBackgroundImage) {
165
+ return splitCssLayers(cssBackgroundImage)
166
+ .filter((layer) => /^linear-gradient\(/i.test(layer.trim()))
167
+ .map((layer) => parseLinearGradient(layer));
168
+ }
169
+
170
+ function linearGradientTransform(cssGradient) {
171
+ const angle = parseLinearGradientAngle(cssGradient);
172
+ const rad = ((angle - 90) * Math.PI) / 180;
173
+ const dx = normalizeZero(Math.cos(rad));
174
+ const dy = normalizeZero(Math.sin(rad));
175
+
176
+ return [
177
+ [dx, dy, normalizeZero(0.5 - dx / 2 - dy / 2)],
178
+ [normalizeZero(-dy), dx, normalizeZero(0.5 + dy / 2 - dx / 2)],
179
+ ];
180
+ }
181
+
182
+ function normalizeZero(value) {
183
+ if (Math.abs(value) < 1e-12) return 0;
184
+ if (Math.abs(value - 1) < 1e-12) return 1;
185
+ if (Math.abs(value + 1) < 1e-12) return -1;
186
+ return value;
187
+ }
188
+
189
+ function parseLinearGradientAngle(cssGradient) {
190
+ const directionMatch = cssGradient.match(/linear-gradient\(\s*to\s+([a-z\s]+?)(?:,|\))/i);
191
+ if (directionMatch) {
192
+ const direction = directionMatch[1].trim().toLowerCase();
193
+ if (direction === 'right') return 90;
194
+ if (direction === 'left') return 270;
195
+ if (direction === 'bottom') return 180;
196
+ if (direction === 'top') return 0;
197
+ if (direction === 'bottom right' || direction === 'right bottom') return 135;
198
+ if (direction === 'bottom left' || direction === 'left bottom') return 225;
199
+ if (direction === 'top right' || direction === 'right top') return 45;
200
+ if (direction === 'top left' || direction === 'left top') return 315;
201
+ }
202
+
203
+ const degMatch = cssGradient.match(/linear-gradient\(\s*(-?[\d.]+)deg/i);
204
+ if (degMatch) {
205
+ const angle = parseFloat(degMatch[1]);
206
+ if (Number.isFinite(angle)) return angle;
207
+ }
208
+
209
+ return 180;
210
+ }
211
+
212
+ function extractGradientStops(css) {
213
+ const stopRegex = /(rgba?\([^)]+\)|#[0-9a-f]{3,8}|transparent)\s*([\d.]+%)?/gi;
214
+ const stops = [];
215
+ let match;
216
+ let index = 0;
217
+
218
+ while ((match = stopRegex.exec(css)) !== null) {
219
+ const color = cssColorToFigma(match[1]);
220
+ const position = match[2] ? parseFloat(match[2]) / 100 : index === 0 ? 0 : 1;
221
+ stops.push({ color, position });
222
+ index++;
223
+ }
224
+
225
+ return stops.length > 0 ? stops : [
226
+ { color: { r: 0, g: 0, b: 0, a: 0 }, position: 0 },
227
+ { color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 },
228
+ ];
229
+ }
230
+
231
+ function splitCssLayers(css) {
232
+ const source = String(css || '');
233
+ const layers = [];
234
+ let current = '';
235
+ let depth = 0;
236
+ let quote = null;
237
+
238
+ for (let index = 0; index < source.length; index++) {
239
+ const char = source[index];
240
+
241
+ if (quote) {
242
+ current += char;
243
+ if (char === quote && source[index - 1] !== '\\') {
244
+ quote = null;
245
+ }
246
+ continue;
247
+ }
248
+
249
+ if (char === '"' || char === "'") {
250
+ quote = char;
251
+ current += char;
252
+ continue;
253
+ }
254
+
255
+ if (char === '(') {
256
+ depth++;
257
+ current += char;
258
+ continue;
259
+ }
260
+
261
+ if (char === ')') {
262
+ depth = Math.max(depth - 1, 0);
263
+ current += char;
264
+ continue;
265
+ }
266
+
267
+ if (char === ',' && depth === 0) {
268
+ if (current.trim()) {
269
+ layers.push(current.trim());
270
+ }
271
+ current = '';
272
+ continue;
273
+ }
274
+
275
+ current += char;
276
+ }
277
+
278
+ if (current.trim()) {
279
+ layers.push(current.trim());
280
+ }
281
+
282
+ return layers;
283
+ }
284
+
285
+ // ─── BORDERS / STROKES ────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * border → Figma strokes
289
+ */
290
+ export function mapBorder(computed) {
291
+ const sides = getBorderSides(computed);
292
+ const visibleSides = Object.values(sides).filter((side) => isRenderableBorderSide(side));
293
+ if (visibleSides.length === 0) return {};
294
+
295
+ const color = cssColorToFigma(visibleSides[0].color);
296
+ const result = {
297
+ strokes: [{
298
+ type: 'SOLID',
299
+ color: { r: color.r, g: color.g, b: color.b },
300
+ opacity: color.a,
301
+ }],
302
+ strokeWeight: visibleSides[0].width,
303
+ strokeAlign: 'INSIDE', // CSS border-box behavior
304
+ };
305
+
306
+ if (!hasUniformBorder(sides)) {
307
+ result.strokeTopWeight = isRenderableBorderSide(sides.top) ? sides.top.width : 0;
308
+ result.strokeRightWeight = isRenderableBorderSide(sides.right) ? sides.right.width : 0;
309
+ result.strokeBottomWeight = isRenderableBorderSide(sides.bottom) ? sides.bottom.width : 0;
310
+ result.strokeLeftWeight = isRenderableBorderSide(sides.left) ? sides.left.width : 0;
311
+ }
312
+
313
+ return result;
314
+ }
315
+
316
+ function getBorderSides(computed) {
317
+ return {
318
+ top: getBorderSide(computed, 'Top', 0),
319
+ right: getBorderSide(computed, 'Right', 1),
320
+ bottom: getBorderSide(computed, 'Bottom', 2),
321
+ left: getBorderSide(computed, 'Left', 3),
322
+ };
323
+ }
324
+
325
+ function getBorderSide(computed, sideName, shorthandIndex) {
326
+ const lowerSide = sideName.toLowerCase();
327
+ return {
328
+ width: parsePx(computed[`border${sideName}Width`] ?? getCssBoxValue(computed.borderWidth, shorthandIndex)),
329
+ style: computed[`border${sideName}Style`] ?? getCssBoxValue(computed.borderStyle, shorthandIndex) ?? 'none',
330
+ color: computed[`border${sideName}Color`] ?? getCssBoxValue(computed.borderColor, shorthandIndex) ?? computed.color ?? '#000',
331
+ side: lowerSide,
332
+ };
333
+ }
334
+
335
+ function isRenderableBorderSide(side) {
336
+ return side.width > 0 && side.style !== 'none' && side.style !== 'hidden' && cssColorToFigma(side.color).a > 0;
337
+ }
338
+
339
+ function hasUniformBorder(sides) {
340
+ const values = Object.values(sides);
341
+ const first = values[0];
342
+ return values.every((side) =>
343
+ isRenderableBorderSide(side) &&
344
+ side.width === first.width &&
345
+ side.style === first.style &&
346
+ normalizeCssValue(side.color) === normalizeCssValue(first.color)
347
+ );
348
+ }
349
+
350
+ function getCssBoxValue(value, index) {
351
+ const parts = splitCssWhitespaceList(value);
352
+ if (parts.length === 0) return null;
353
+ if (parts.length === 1) return parts[0];
354
+ if (parts.length === 2) return index === 0 || index === 2 ? parts[0] : parts[1];
355
+ if (parts.length === 3) return index === 0 ? parts[0] : index === 2 ? parts[2] : parts[1];
356
+ return parts[index] ?? null;
357
+ }
358
+
359
+ function splitCssWhitespaceList(value) {
360
+ const source = String(value || '').trim();
361
+ if (!source) return [];
362
+
363
+ const parts = [];
364
+ let current = '';
365
+ let depth = 0;
366
+
367
+ for (let index = 0; index < source.length; index++) {
368
+ const char = source[index];
369
+ if (char === '(') depth++;
370
+ if (char === ')') depth = Math.max(depth - 1, 0);
371
+
372
+ if (/\s/.test(char) && depth === 0) {
373
+ if (current.trim()) {
374
+ parts.push(current.trim());
375
+ }
376
+ current = '';
377
+ continue;
378
+ }
379
+
380
+ current += char;
381
+ }
382
+
383
+ if (current.trim()) {
384
+ parts.push(current.trim());
385
+ }
386
+
387
+ return parts;
388
+ }
389
+
390
+ function normalizeCssValue(value) {
391
+ return String(value || '').replace(/\s+/g, '').toLowerCase();
392
+ }
393
+
394
+ // ─── EFFECTS ─────────────────────────────────────────────────────────────────
395
+
396
+ /**
397
+ * box-shadow → Figma DROP_SHADOW effect
398
+ * Handles: "0 0 30px 10px rgba(201,168,76,0.3)"
399
+ */
400
+ export function mapBoxShadow(computed) {
401
+ if (!computed.boxShadow || computed.boxShadow === 'none') return [];
402
+
403
+ const parts = computed.boxShadow.match(
404
+ /(?:(rgba?\([^)]+\)|#[0-9a-f]{3,8})\s+)?(-?[\d.]+px)\s+(-?[\d.]+px)\s+([\d.]+px)\s*([\d.]+px)?(?:\s+(rgba?\([^)]+\)|#[0-9a-f]{3,8}))?/i
405
+ );
406
+ if (!parts) return [];
407
+
408
+ const [, leadingColor, x, y, blur, spread = '0px', trailingColor] = parts;
409
+ const colorStr = leadingColor || trailingColor;
410
+ if (!colorStr) return [];
411
+ const color = cssColorToFigma(colorStr);
412
+
413
+ return [{
414
+ type: 'DROP_SHADOW',
415
+ color: { r: color.r, g: color.g, b: color.b, a: color.a },
416
+ offset: { x: parsePx(x), y: parsePx(y) },
417
+ radius: parsePx(blur),
418
+ spread: parsePx(spread),
419
+ visible: true,
420
+ blendMode: 'NORMAL',
421
+ }];
422
+ }
423
+
424
+ // ─── TYPOGRAPHY ───────────────────────────────────────────────────────────────
425
+
426
+ /**
427
+ * CSS text properties → Figma text node properties
428
+ */
429
+ export function mapTypography(computed, fontMap = {}, parentComputed = null) {
430
+ const familyRaw = computed.fontFamily?.split(',')[0].replace(/['"]/g, '').trim() ?? 'Inter';
431
+ const weight = computed.fontWeight ?? '400';
432
+ const isItalic = computed.fontStyle === 'italic';
433
+ const fontKey = `${computed.fontFamily}|${weight}|${isItalic ? 'italic' : 'normal'}`;
434
+
435
+ const font = fontMap?.[fontKey] ?? { family: familyRaw, style: 'Regular' };
436
+ const fontSize = parsePx(computed.fontSize) || 16;
437
+
438
  const result = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  fontName: font,
440
  fontSize,
441
  lineHeight: lineHeightToFigma(computed.lineHeight, computed.fontSize),
442
  letterSpacing: {
443
  value: letterSpacingToPx(computed.letterSpacing, computed.fontSize),
444
+ unit: 'PIXELS',
445
+ },
446
+ textAlignHorizontal: TEXT_ALIGN_MAP[computed.textAlign] ?? 'LEFT',
447
+ textCase: TEXT_CASE_MAP[computed.textTransform] ?? 'ORIGINAL',
448
+ // -webkit-text-stroke → outline text (color: transparent + stroke)
449
  fills: isTransparentCssColor(computed.color)
450
  ? []
451
  : [solidPaint(computed.color)],
452
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
454
+ if (shouldTruncateText(computed, parentComputed)) {
455
+ result.textTruncation = 'ENDING';
 
 
 
 
 
 
456
  }
457
 
458
+ return result;
 
 
 
 
459
  }
460
+
461
+ /**
462
+ * Map -webkit-text-stroke to Figma strokes on a text node.
463
+ */
464
+ export function mapTextStroke(computed) {
465
+ // CSS doesn't expose webkit-text-stroke in getComputedStyle reliably,
466
+ // but if fill is transparent we know it's outline text
467
+ if (isTransparentCssColor(computed.color) && parsePx(computed.webkitTextStrokeWidth) > 0) {
468
+ const width = parsePx(computed.webkitTextStrokeWidth);
469
+ const color = cssColorToFigma(computed.webkitTextStrokeColor ?? '#000');
470
+ return {
471
+ strokes: [{
472
+ type: 'SOLID',
473
+ color: { r: color.r, g: color.g, b: color.b },
474
+ opacity: color.a,
475
+ }],
476
+ strokeWeight: width,
477
+ strokeAlign: 'OUTSIDE',
478
+ };
479
+ }
480
+ return {};
481
+ }
482
+
483
+ // ─── POSITIONING ─────────────────────────────────────────────────────────────
484
+
485
+ /**
486
+ * position: absolute → Figma absolute positioning
487
+ */
488
+ export function mapPositioning(computed, rect, parentRect) {
489
+ if (computed.position !== 'absolute' && computed.position !== 'fixed') {
490
+ return {};
491
+ }
492
+
493
+ return {
494
+ layoutPositioning: 'ABSOLUTE',
495
+ x: rect.x - (parentRect?.x ?? 0),
496
+ y: rect.y - (parentRect?.y ?? 0),
497
+ };
498
+ }
src/figma/font-resolver.js CHANGED
@@ -1,15 +1,15 @@
1
- /**
2
- * src/figma/font-resolver.js
3
- * Resolves CSS font families and weights to Figma font names.
4
- * Builds a font map for the entire DOM tree before node creation.
5
- *
6
- * NOTE: This module runs in Node.js (not inside Figma).
7
- * It outputs a fontMap that the Figma plugin reads and pre-loads.
8
- */
9
-
10
- import { walk } from '../core/dom-tree.js';
11
- import { WEIGHT_MAP } from '../utils/units.js';
12
-
13
  // Known Google Fonts available in Figma + their available styles
14
  const FIGMA_FONT_STYLES = {
15
  'Playfair Display': ['Thin', 'ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black',
@@ -25,14 +25,14 @@ const FIGMA_FONT_STYLES = {
25
  Georgia: ['Regular', 'Italic', 'Bold', 'Bold Italic'],
26
  'Courier New': ['Regular', 'Italic', 'Bold', 'Bold Italic'],
27
  };
28
-
29
- /**
30
- * @param {object} domTree
31
- * @returns {Promise<FontMap>} Map of "family|weight|italic" → { family, style }
32
- */
33
- export async function resolveFonts(domTree) {
34
- const needed = new Set();
35
-
36
  walk(domTree, (node) => {
37
  const { fontFamily, fontWeight, fontStyle } = node.computed ?? {};
38
  if (fontFamily) {
@@ -54,21 +54,21 @@ export async function resolveFonts(domTree) {
54
  needed.add(key);
55
  }
56
  });
57
-
58
  const fontMap = {};
59
  for (const key of needed) {
60
  const [family, weight, style] = key.split('|');
61
  const resolved = resolveFont(family, weight, style === 'italic');
62
  fontMap[key] = resolved;
63
  }
64
-
65
- return fontMap;
66
- }
67
-
68
- /**
69
- * Strip quotes from CSS font-family string.
70
- * e.g. "'Playfair Display', serif" → "Playfair Display"
71
- */
72
  function cleanFamilyName(css) {
73
  return getFontFamilyStack(css)[0] ?? '';
74
  }
@@ -83,11 +83,14 @@ function resolveFont(cssFamily, weightStr, isItalic) {
83
  const styleName = WEIGHT_MAP[weight] ?? 'Regular';
84
  const italicSuffix = isItalic ? ' Italic' : '';
85
  const targetStyle = styleName === 'Regular' && isItalic ? 'Italic' : `${styleName}${italicSuffix}`;
 
86
 
87
  const candidates = [];
88
  const availableStackFamily = stack.find((name) => FIGMA_FONT_STYLES[name]);
89
  if (availableStackFamily) {
90
  candidates.push(availableStackFamily);
 
 
91
  } else {
92
  const generic = getGenericFontFamily(stack);
93
  if (generic === 'serif') {
@@ -109,7 +112,9 @@ function resolveFont(cssFamily, weightStr, isItalic) {
109
 
110
  for (const candidate of candidates) {
111
  const styles = FIGMA_FONT_STYLES[candidate];
112
- if (!styles) continue;
 
 
113
  if (styles.includes(targetStyle)) {
114
  return { family: candidate, style: targetStyle };
115
  }
@@ -141,6 +146,28 @@ function getGenericFontFamily(stack) {
141
  return null;
142
  }
143
 
144
- /**
145
- * @typedef {Record<string, { family: string, style: string }>} FontMap
146
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * src/figma/font-resolver.js
3
+ * Resolves CSS font families and weights to Figma font names.
4
+ * Builds a font map for the entire DOM tree before node creation.
5
+ *
6
+ * NOTE: This module runs in Node.js (not inside Figma).
7
+ * It outputs a fontMap that the Figma plugin reads and pre-loads.
8
+ */
9
+
10
+ import { walk } from '../core/dom-tree.js';
11
+ import { WEIGHT_MAP } from '../utils/units.js';
12
+
13
  // Known Google Fonts available in Figma + their available styles
14
  const FIGMA_FONT_STYLES = {
15
  'Playfair Display': ['Thin', 'ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black',
 
25
  Georgia: ['Regular', 'Italic', 'Bold', 'Bold Italic'],
26
  'Courier New': ['Regular', 'Italic', 'Bold', 'Bold Italic'],
27
  };
28
+
29
+ /**
30
+ * @param {object} domTree
31
+ * @returns {Promise<FontMap>} Map of "family|weight|italic" → { family, style }
32
+ */
33
+ export async function resolveFonts(domTree) {
34
+ const needed = new Set();
35
+
36
  walk(domTree, (node) => {
37
  const { fontFamily, fontWeight, fontStyle } = node.computed ?? {};
38
  if (fontFamily) {
 
54
  needed.add(key);
55
  }
56
  });
57
+
58
  const fontMap = {};
59
  for (const key of needed) {
60
  const [family, weight, style] = key.split('|');
61
  const resolved = resolveFont(family, weight, style === 'italic');
62
  fontMap[key] = resolved;
63
  }
64
+
65
+ return fontMap;
66
+ }
67
+
68
+ /**
69
+ * Strip quotes from CSS font-family string.
70
+ * e.g. "'Playfair Display', serif" → "Playfair Display"
71
+ */
72
  function cleanFamilyName(css) {
73
  return getFontFamilyStack(css)[0] ?? '';
74
  }
 
83
  const styleName = WEIGHT_MAP[weight] ?? 'Regular';
84
  const italicSuffix = isItalic ? ' Italic' : '';
85
  const targetStyle = styleName === 'Regular' && isItalic ? 'Italic' : `${styleName}${italicSuffix}`;
86
+ const requestedNamedFamily = getRequestedNamedFamily(stack);
87
 
88
  const candidates = [];
89
  const availableStackFamily = stack.find((name) => FIGMA_FONT_STYLES[name]);
90
  if (availableStackFamily) {
91
  candidates.push(availableStackFamily);
92
+ } else if (requestedNamedFamily) {
93
+ candidates.push(requestedNamedFamily);
94
  } else {
95
  const generic = getGenericFontFamily(stack);
96
  if (generic === 'serif') {
 
112
 
113
  for (const candidate of candidates) {
114
  const styles = FIGMA_FONT_STYLES[candidate];
115
+ if (!styles) {
116
+ return { family: candidate, style: targetStyle };
117
+ }
118
  if (styles.includes(targetStyle)) {
119
  return { family: candidate, style: targetStyle };
120
  }
 
146
  return null;
147
  }
148
 
149
+ function getRequestedNamedFamily(stack) {
150
+ if (!Array.isArray(stack)) {
151
+ return null;
152
+ }
153
+
154
+ return stack.find((name) => !isGenericFontFamily(name)) || null;
155
+ }
156
+
157
+ function isGenericFontFamily(name) {
158
+ const normalized = String(name || '').toLowerCase();
159
+ return normalized === 'serif'
160
+ || normalized === 'sans-serif'
161
+ || normalized === 'monospace'
162
+ || normalized === 'cursive'
163
+ || normalized === 'fantasy'
164
+ || normalized === 'system-ui'
165
+ || normalized === 'ui-serif'
166
+ || normalized === 'ui-sans-serif'
167
+ || normalized === 'ui-monospace'
168
+ || normalized === 'ui-rounded';
169
+ }
170
+
171
+ /**
172
+ * @typedef {Record<string, { family: string, style: string }>} FontMap
173
+ */
src/figma/mapper.js CHANGED
@@ -1,106 +1,138 @@
1
- /**
2
- * src/figma/mapper.js
3
- * Converts the annotated DOM tree (with z-index) into
4
- * a Figma node tree JSON that the Figma plugin can execute.
5
- *
6
- * Output format: array of FigmaNode instructions
7
- * that the plugin reads and calls figma.create* for each.
8
- */
9
-
10
- import {
11
- mapFlexLayout,
12
- mapPadding,
13
- mapOverflow,
14
- mapBorderRadius,
15
- mapBackgroundColor,
16
- mapBorder,
17
  mapBoxShadow,
18
  mapTypography,
19
  mapTextStroke,
 
20
  parseLinearGradient,
21
  parseLinearGradientLayers,
22
  } from './css-to-figma.js';
23
- import { cssColorToFigma, solidPaint as colorSolidPaint } from '../utils/color.js';
24
- import { parsePx } from '../utils/units.js';
25
-
26
- /**
27
- * @param {{ annotated: object, sortedFlat: object[] }} sorted
28
- * @param {{ pseudoElements, gridStrategies, hoverSpecs, fontMap }} extras
29
- * @returns {FigmaNode[]}
30
- */
31
  export function buildFigmaTree({ annotated }, { pseudoElements = [], gridStrategies = {}, hoverSpecs = {}, fontMap = {} } = {}) {
32
  attachPseudoElements(annotated, pseudoElements);
33
  const normalizedRoot = normalizeRootStructure(annotated);
34
 
35
  // Build the main node tree
36
- return [buildNode(normalizedRoot, null, { fontMap, gridStrategies, hoverSpecs }, '0')];
37
  }
38
-
39
  function buildNode(node, parentContext, ctx, path) {
40
- const { computed, rect, tag, text, textRuns = [], children = [], classList, isTextContainer, _pageLayout, _role, svgMarkup } = node;
41
- const resolvedRect = resolveRenderedRect(node, parentContext);
42
  const parentResolvedRect = parentContext?.resolvedRect ?? null;
43
  const isLeafText = Boolean(text) && children.length === 0;
44
  const isText = isLeafText && Boolean(isTextContainer);
 
 
 
 
 
45
  const isSvg = tag === 'svg' && Boolean(svgMarkup);
46
-
47
- const base = {
48
- id: buildStableId(tag, classList, path),
49
- name: buildName(tag, classList),
50
- type: isSvg ? 'SVG' : (isText && text ? 'TEXT' : 'FRAME'),
51
- x: Math.round(resolvedRect.x - (parentResolvedRect?.x ?? 0)),
52
- y: Math.round(resolvedRect.y - (parentResolvedRect?.y ?? 0)),
53
- width: Math.round(resolvedRect.width),
54
- height: Math.round(resolvedRect.height),
55
- };
56
-
57
- if (isSvg) {
58
- const isAbsolute = computed.position === 'absolute' || computed.position === 'fixed';
59
- return {
60
- ...base,
61
- _svgMarkup: svgMarkup,
62
- opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
63
- ...(isAbsolute ? { layoutPositioning: 'ABSOLUTE' } : {}),
64
- ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? {
65
- blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'),
66
- } : {}),
67
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
 
70
  if (base.type === 'TEXT') {
 
 
 
 
 
 
 
71
  return {
72
  ...base,
73
  characters: text,
74
- ...mapTypography(computed, ctx.fontMap),
75
  ...mapFlexTextAlignment(computed),
76
  ...mapTextStroke(computed),
77
  textRuns: buildTextRuns(textRuns, ctx.fontMap),
78
  opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
79
- };
80
- }
81
-
82
- // Frame node
83
- const isGrid = computed.display === 'grid';
84
- const isFlex = computed.display === 'flex' || computed.display === 'inline-flex';
85
- const isInlineBlock = computed.display === 'inline-block';
86
- const isAbsolute = computed.position === 'absolute' || computed.position === 'fixed';
87
-
88
- const layout = isFlex
89
- ? getRenderableFlexLayout(node)
90
- : isInlineBlock
91
- ? getRenderableInlineLayout(node)
92
- : null;
93
-
94
- // Check if a grid strategy was provided for this element
95
- const gridClass = classList?.find(c => ctx.gridStrategies?.[`.${c}`]);
96
- const gridStrategy = gridClass ? ctx.gridStrategies[`.${gridClass}`] : null;
97
-
98
- // Check hover spec
99
- const hoverClass = classList?.find(c => ctx.hoverSpecs?.[`.${c}`]);
100
- const hoverSpec = hoverClass ? ctx.hoverSpecs[`.${hoverClass}`] : null;
101
-
102
  // Background fills
103
- const fills = mapBackgroundColor(computed);
104
  const backgroundPattern = detectBackgroundPattern(computed);
105
 
106
  // Handle linear-gradient in backgroundImage
@@ -110,944 +142,1297 @@ function buildNode(node, parentContext, ctx, path) {
110
  } catch { /* skip malformed gradients */ }
111
  }
112
 
113
- const frameNode = {
114
- ...base,
115
- ...(_pageLayout ? { _pageLayout: true } : {}),
116
- ...(_role ? { _role } : {}),
117
- fills,
118
- ...mapPadding(computed),
119
- ...mapOverflow(computed),
120
- ...mapBorderRadius(computed, rect),
121
- ...mapBorder(computed),
122
- effects: mapBoxShadow(computed),
123
- opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
124
- ...(layout || {}),
125
- ...(isAbsolute ? { layoutPositioning: 'ABSOLUTE' } : {}),
126
- ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? {
127
- blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'),
128
- } : {}),
129
- };
130
-
131
- // Apply grid strategy when a renderable fallback is available
132
- const renderableGridStrategy = isGrid ? getRenderableGridStrategy(node, gridStrategy) : null;
133
- if (renderableGridStrategy) {
134
- frameNode._gridStrategy = renderableGridStrategy;
135
- frameNode._gridNotes = gridStrategy.notes;
136
  }
137
 
138
- // Attach hover spec for Figma plugin to create variants
139
- if (hoverSpec) {
140
- frameNode._hoverSpec = hoverSpec;
141
- }
142
- if (backgroundPattern) {
143
- frameNode._backgroundPattern = backgroundPattern;
144
- }
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  // Recurse
147
  const childNodes = [];
 
148
 
149
  if (isLeafText) {
150
- childNodes.push(buildEmbeddedTextNode(node, ctx, `${path}.text`, resolvedRect));
151
- }
152
-
153
- const controlTextNode = buildFormControlTextNode(node, ctx, `${path}.control`, resolvedRect);
154
- if (controlTextNode) {
155
- childNodes.push(controlTextNode);
156
- }
157
-
158
- const pseudoChildren = (node.pseudoChildren || []).concat(getNativePseudoChildren(node));
159
- const mergeablePseudoBackgrounds = [];
160
- const renderablePseudoChildren = [];
161
-
162
- for (const pseudo of pseudoChildren) {
163
- if (shouldMergePseudoIntoParent(node, pseudo)) {
164
- mergeablePseudoBackgrounds.push(...buildMergedPseudoBackgrounds(pseudo));
165
- continue;
166
- }
167
- renderablePseudoChildren.push(pseudo);
168
- }
169
-
170
- const pseudoBefore = renderablePseudoChildren
171
- .filter((pseudo) => pseudo.zOrder !== 'top')
172
- .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudo.${index}`, ctx))
173
- .filter(Boolean);
174
- const pseudoTop = renderablePseudoChildren
175
- .filter((pseudo) => pseudo.zOrder === 'top')
176
- .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudoTop.${index}`, ctx))
177
- .filter(Boolean);
178
-
179
- frameNode.children = pseudoBefore
180
- .concat(childNodes)
181
- .concat(
182
- children
183
- .map((child, index) => buildNode(child, { sourceRect: rect, resolvedRect }, ctx, `${path}.${index}`))
 
 
 
 
 
 
 
184
  .filter(Boolean)
185
  )
186
  .concat(pseudoTop);
187
-
188
- if (mergeablePseudoBackgrounds.length > 0) {
189
- frameNode.fills = frameNode.fills.concat(mergeablePseudoBackgrounds);
190
- }
191
-
192
- return frameNode;
193
- }
194
-
195
- function resolveRenderedRect(node, parentContext) {
196
- const sourceRect = node?.rect || { x: 0, y: 0, width: 0, height: 0 };
197
- if (!parentContext?.sourceRect || !parentContext?.resolvedRect) {
198
- return sourceRect;
199
- }
200
-
201
- const resolved = reprojectRectWithinParent(sourceRect, parentContext.sourceRect, parentContext.resolvedRect);
202
- if (shouldStretchAspectWrapper(node, parentContext)) {
203
- return {
204
- ...resolved,
205
- width: parentContext.resolvedRect.width,
206
- height: parentContext.resolvedRect.height,
207
- x: parentContext.resolvedRect.x + (sourceRect.x - parentContext.sourceRect.x),
208
- y: parentContext.resolvedRect.y + (sourceRect.y - parentContext.sourceRect.y),
209
- };
210
- }
211
-
212
- return resolved;
213
- }
214
-
215
- function reprojectRectWithinParent(childRect, sourceParentRect, resolvedParentRect) {
216
- const rect = childRect || { x: 0, y: 0, width: 0, height: 0 };
217
- const sourceParent = sourceParentRect || { x: 0, y: 0, width: 0, height: 0 };
218
- const resolvedParent = resolvedParentRect || sourceParent;
219
- const tolerance = 1.5;
220
-
221
- if (isSameRect(sourceParent, resolvedParent)) {
222
- return rect;
223
- }
224
-
225
- const leftOffset = (rect.x ?? 0) - (sourceParent.x ?? 0);
226
- const topOffset = (rect.y ?? 0) - (sourceParent.y ?? 0);
227
- const rightOffset = (sourceParent.x ?? 0) + (sourceParent.width ?? 0) - ((rect.x ?? 0) + (rect.width ?? 0));
228
- const bottomOffset = (sourceParent.y ?? 0) + (sourceParent.height ?? 0) - ((rect.y ?? 0) + (rect.height ?? 0));
229
-
230
- const fillsHorizontal = isClose(leftOffset, 0, tolerance)
231
- && isClose(rightOffset, 0, tolerance)
232
- && isClose(rect.width ?? 0, sourceParent.width ?? 0, tolerance);
233
- const fillsVertical = isClose(topOffset, 0, tolerance)
234
- && isClose(bottomOffset, 0, tolerance)
235
- && isClose(rect.height ?? 0, sourceParent.height ?? 0, tolerance);
236
-
237
- const width = fillsHorizontal ? resolvedParent.width : rect.width;
238
- const height = fillsVertical ? resolvedParent.height : rect.height;
239
-
240
- const x = fillsHorizontal
241
- ? resolvedParent.x + leftOffset
242
- : (rightOffset < leftOffset
243
- ? resolvedParent.x + resolvedParent.width - rightOffset - width
244
- : resolvedParent.x + leftOffset);
245
-
246
- const y = fillsVertical
247
- ? resolvedParent.y + topOffset
248
- : (bottomOffset < topOffset
249
- ? resolvedParent.y + resolvedParent.height - bottomOffset - height
250
- : resolvedParent.y + topOffset);
251
-
252
- return {
253
- x,
254
- y,
255
- width,
256
- height,
257
- };
258
- }
259
-
260
- function shouldStretchAspectWrapper(node, parentContext) {
261
- if (!node?.rect || !parentContext?.sourceRect || !parentContext?.resolvedRect) {
262
- return false;
263
- }
264
-
265
- if (node.computed?.position === 'absolute' || node.computed?.position === 'fixed') {
266
- return false;
267
- }
268
-
269
- if (parsePx(node.computed?.paddingBottom) <= 0) {
270
- return false;
271
- }
272
-
273
- if (!Array.isArray(node.children) || node.children.length === 0) {
274
- return false;
275
- }
276
-
277
- if (node.children.some((child) => !isAbsoluteLikeNode(child))) {
278
- return false;
279
- }
280
-
281
- if (node.pseudoChildren?.length > 0 || node?.pseudo?.before || node?.pseudo?.after) {
282
- return false;
283
- }
284
-
285
- const sourceRect = node.rect;
286
- const parentRect = parentContext.sourceRect;
287
- const widthMatches = isClose(sourceRect.width, parentRect.width, 2);
288
- const xMatches = isClose(sourceRect.x, parentRect.x, 2);
289
- const yMatches = isClose(sourceRect.y, parentRect.y, 2);
290
- const isShorter = sourceRect.height + 2 < parentRect.height;
291
-
292
- return widthMatches && xMatches && yMatches && isShorter;
293
- }
294
-
295
- function isClose(a, b, tolerance = 1.5) {
296
- return Math.abs((a ?? 0) - (b ?? 0)) <= tolerance;
297
- }
298
-
299
- function isSameRect(a, b, tolerance = 0.01) {
300
- return isClose(a?.x, b?.x, tolerance)
301
- && isClose(a?.y, b?.y, tolerance)
302
- && isClose(a?.width, b?.width, tolerance)
303
- && isClose(a?.height, b?.height, tolerance);
304
- }
305
-
306
- function getNativePseudoChildren(node) {
307
- const result = [];
308
- const pseudo = node?.pseudo || {};
309
- const rect = node?.rect || { x: 0, y: 0 };
310
-
311
- for (const type of ['before', 'after']) {
312
- const entry = pseudo[type];
313
- if (!entry?.rect) continue;
314
-
315
- result.push({
316
- ...entry,
317
- x: entry.rect.x - rect.x,
318
- y: entry.rect.y - rect.y,
319
- width: entry.rect.width,
320
- height: entry.rect.height,
321
- zOrder: entry.zOrder || (type === 'before' ? 'bottom' : 'top'),
322
- });
323
- }
324
-
325
- return result;
326
- }
327
-
328
- function buildPseudoNode(pseudo, path, ctx = {}) {
329
- const pseudoId = `pseudo-${path}-${pseudo.name.replace(/\s+/g, '-').toLowerCase()}`;
330
- const isTextPseudo = pseudo.type === 'text' && Boolean(pseudo.content);
331
- const pseudoBackgrounds = isTextPseudo ? [] : buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor);
332
- const pseudoEffects = pseudo.computed ? mapBoxShadow(pseudo.computed) : [];
333
- const pseudoStrokes = pseudo.computed ? mapBorder(pseudo.computed) : {};
334
- const textTypography = pseudo.computed
335
- ? {
336
- ...mapTypography(pseudo.computed, ctx.fontMap),
337
- ...mapTextStroke(pseudo.computed),
338
- }
339
- : {
340
- fontName: {
341
- family: 'Inter',
342
- style: 'Regular',
343
- },
344
- fontSize: Math.max(Math.min(Math.round(pseudo.height || 16), 48), 12),
345
- fills: pseudo.fillColor && pseudo.fillColor !== 'noise-texture'
346
- ? [colorSolidPaint(pseudo.fillColor)]
347
- : [colorSolidPaint('#ffffff')],
348
- };
349
-
350
- return {
351
- id: pseudoId,
352
- name: `[pseudo] ${pseudo.name}`,
353
- type: 'FRAME',
354
- x: Math.round(pseudo.x),
355
- y: Math.round(pseudo.y),
356
- width: Math.round(pseudo.width),
357
- height: Math.round(pseudo.height),
358
- opacity: roundFloat(pseudo.opacity ?? 1),
359
- fills: pseudoBackgrounds,
360
- ...pseudoStrokes,
361
- effects: pseudoEffects,
362
- _isPseudo: true,
363
- _pseudoType: pseudo.type,
364
- _pseudoPosition: pseudo.position,
365
- children: pseudo.content ? [{
366
- id: `${pseudoId}-content`,
367
- name: 'content',
368
- type: 'TEXT',
369
- characters: pseudo.content,
370
- x: 0, y: 0,
371
- width: pseudo.width,
372
- height: pseudo.height,
373
- ...textTypography,
374
- }] : [],
375
- };
376
- }
377
-
378
- function buildFormControlTextNode(node, ctx, path, resolvedRect = null) {
379
- const rendered = resolveFormControlText(node.formControl);
380
- if (!rendered) {
381
- return null;
382
- }
383
-
384
- const computed = rendered.kind === 'placeholder'
385
- ? mergeFormControlTextStyles(node.computed, node.formControl?.placeholderComputed)
386
- : node.computed;
387
-
388
- return buildEmbeddedTextNode(
389
- {
390
- ...node,
391
- text: rendered.text,
392
- textRuns: [{
393
- text: rendered.text,
394
- lineIndex: 0,
395
- computed,
396
- }],
397
- computed,
398
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  ctx,
400
  path,
401
  resolvedRect,
402
- rendered.kind
 
 
403
  );
404
  }
405
-
406
- function resolveFormControlText(formControl) {
407
- if (!formControl) {
408
- return null;
409
- }
410
-
411
- const value = normalizeControlText(formControl.value);
412
- if (value) {
413
- return { kind: 'value', text: value };
414
- }
415
-
416
- const placeholder = normalizeControlText(formControl.placeholder);
417
- if (placeholder) {
418
- return { kind: 'placeholder', text: placeholder };
419
- }
420
-
421
- return null;
422
- }
423
-
424
- function normalizeControlText(value) {
425
- return String(value || '').replace(/\r/g, '').trim();
426
- }
427
-
428
- function mergeFormControlTextStyles(baseComputed, overrideComputed) {
429
- if (!overrideComputed) {
430
- return baseComputed;
431
- }
432
-
433
- const merged = { ...baseComputed };
434
- const textKeys = [
435
- 'fontFamily',
436
- 'fontSize',
437
- 'fontWeight',
438
- 'fontStyle',
439
- 'lineHeight',
440
- 'letterSpacing',
441
- 'textAlign',
442
- 'textTransform',
443
- 'color',
444
- 'opacity',
445
- 'textDecoration',
446
- 'webkitTextStrokeWidth',
447
- 'webkitTextStrokeColor',
448
- ];
449
-
450
- for (const key of textKeys) {
451
- if (overrideComputed[key] !== undefined && overrideComputed[key] !== null && overrideComputed[key] !== '') {
452
- merged[key] = overrideComputed[key];
453
- }
454
- }
455
-
456
- return merged;
457
- }
458
-
459
- function buildPseudoBackgrounds(computed, fallbackFillColor) {
460
- if (!computed) {
461
- return fallbackFillColor && fallbackFillColor !== 'noise-texture'
462
- ? [colorSolidPaint(fallbackFillColor)]
463
- : [];
464
- }
465
-
466
- const fills = mapBackgroundColor(computed);
467
- if (computed.backgroundImage && computed.backgroundImage.includes('linear-gradient')) {
468
- fills.push(...parseLinearGradientLayers(computed.backgroundImage));
469
- }
470
-
471
- if (fills.length === 0 && fallbackFillColor && fallbackFillColor !== 'noise-texture') {
472
- fills.push(colorSolidPaint(fallbackFillColor));
473
- }
474
-
475
- return fills;
476
- }
477
-
478
- function buildMergedPseudoBackgrounds(pseudo) {
479
- const paints = buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor);
480
- const opacity = Number.isFinite(pseudo.opacity) ? pseudo.opacity : 1;
481
- return paints.map((paint) => applyPaintOpacity(paint, opacity));
482
- }
483
-
484
- function shouldMergePseudoIntoParent(node, pseudo) {
485
- if (!node?.computed || !pseudo || pseudo.type === 'text' || pseudo.zOrder !== 'bottom') {
486
- return false;
487
- }
488
-
489
- const position = pseudo.position;
490
- if (position !== 'absolute' && position !== 'fixed') {
491
- return false;
492
- }
493
-
494
- if (!isTransparentCssBackground(node.computed) || !pseudo.rect || !node.rect) {
495
- return false;
496
- }
497
-
498
- const parent = node.rect;
499
- const child = pseudo.rect;
500
- const tolerance = 1.5;
501
- const coversParent =
502
- Math.abs((child.x ?? 0) - (parent.x ?? 0)) <= tolerance &&
503
- Math.abs((child.y ?? 0) - (parent.y ?? 0)) <= tolerance &&
504
- Math.abs((child.width ?? 0) - (parent.width ?? 0)) <= tolerance &&
505
- Math.abs((child.height ?? 0) - (parent.height ?? 0)) <= tolerance;
506
-
507
- if (!coversParent) {
508
- return false;
509
- }
510
-
511
- return buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor).length > 0;
512
- }
513
-
514
- function isTransparentCssBackground(computed) {
515
- const backgroundColor = computed?.backgroundColor || '';
516
- const backgroundImage = computed?.backgroundImage || '';
517
- return isTransparentCssColor(backgroundColor) && backgroundImage === 'none';
518
- }
519
-
520
- function isTransparentCssColor(value) {
521
- if (!value || value === 'transparent' || value === 'none') {
522
- return true;
523
- }
524
- return cssColorToFigma(value).a === 0;
525
- }
526
-
527
  function applyPaintOpacity(paint, opacity) {
528
  if (!paint || opacity === 1 || !Number.isFinite(opacity)) {
529
  return paint;
530
  }
531
-
532
- const copy = JSON.parse(JSON.stringify(paint));
533
- const existing = Number.isFinite(copy.opacity) ? copy.opacity : 1;
534
  copy.opacity = existing * opacity;
535
  return copy;
536
  }
537
 
538
- function buildName(tag, classList) {
539
- if (classList?.length > 0) return `${tag}.${classList.slice(0, 2).join('.')}`;
540
- return tag;
541
  }
542
 
543
- function buildStableId(tag, classList, path) {
544
- const slug = (classList?.slice(0, 2).join('-') || 'el')
545
- .replace(/[^a-zA-Z0-9_-]+/g, '-')
546
- .replace(/-+/g, '-')
547
- .replace(/^-|-$/g, '')
548
- || 'el';
549
-
550
- return `${tag}-${slug}-${path.replace(/\./g, '-')}`;
551
- }
552
 
553
- function roundFloat(value, precision = 4) {
554
- const factor = 10 ** precision;
555
- return Math.round((value + Number.EPSILON) * factor) / factor;
556
  }
557
-
558
- function buildEmbeddedTextNode(node, ctx, path, resolvedRect = null, nameSuffix = 'text') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  const { computed, rect, tag, text, textRuns = [], classList } = node;
560
  const insetX = parsePx(computed.paddingLeft);
561
  const insetY = parsePx(computed.paddingTop);
562
  const sourceRect = resolvedRect || rect;
563
- const width = Math.max(Math.round(sourceRect.width - insetX - parsePx(computed.paddingRight)), 1);
564
- const height = Math.max(Math.round(sourceRect.height - insetY - parsePx(computed.paddingBottom)), 1);
 
 
 
 
 
 
 
 
 
 
 
 
 
565
 
566
  return {
567
  id: buildStableId(tag, classList, `${path}-inner`),
568
  name: `${buildName(tag, classList)} / ${nameSuffix}`,
569
  type: 'TEXT',
570
- x: Math.round(insetX),
571
- y: Math.round(insetY),
572
- width,
573
- height,
574
  characters: text,
575
- ...mapTypography(computed, ctx.fontMap),
576
  ...mapFlexTextAlignment(computed),
577
  ...mapTextStroke(computed),
578
  textRuns: buildTextRuns(textRuns, ctx.fontMap),
579
  };
580
  }
581
 
582
- function attachPseudoElements(root, pseudoElements) {
583
- if (!root || !Array.isArray(pseudoElements) || pseudoElements.length === 0) return;
584
-
585
- for (const pseudo of pseudoElements) {
586
- const target = findBestPseudoParent(root, pseudo) || root;
587
- const relative = {
588
- ...pseudo,
589
- x: Math.round(pseudo.x - (target.rect?.x ?? 0)),
590
- y: Math.round(pseudo.y - (target.rect?.y ?? 0)),
591
- };
592
- if (!target.pseudoChildren) {
593
- target.pseudoChildren = [];
594
- }
595
- target.pseudoChildren.push(relative);
596
- }
597
- }
598
-
599
- function normalizeRootStructure(root) {
600
- if (!root || root.tag !== 'body' || !Array.isArray(root.children) || root.children.length === 0) {
601
- return root;
602
- }
603
-
604
- const headerChildren = root.children.filter((child) => isTopHeaderChild(child, root.rect));
605
- if (headerChildren.length === 0 || headerChildren.length === root.children.length) {
606
- return {
607
- ...root,
608
- _pageLayout: true,
609
- };
610
- }
611
-
612
- const otherChildren = root.children.filter((child) => !isTopHeaderChild(child, root.rect));
613
- const syntheticHeader = buildSyntheticGroup('header', headerChildren);
614
- return {
615
- ...root,
616
- _pageLayout: true,
617
- children: [syntheticHeader].concat(otherChildren),
618
- };
619
- }
620
-
621
- function isTopHeaderChild(node, rootRect) {
622
- if (!node?.rect || !node?.computed) return false;
623
-
624
- const position = node.computed.position;
625
- if (position !== 'fixed' && position !== 'absolute') {
626
- return false;
627
  }
628
 
629
- const nearTop = Math.abs((node.rect.y ?? 0) - (rootRect?.y ?? 0)) <= 8;
630
- const wideEnough = (node.rect.width ?? 0) >= Math.max((rootRect?.width ?? 0) * 0.6, 320);
631
- const shortEnough = (node.rect.height ?? 0) <= Math.max((rootRect?.height ?? 0) * 0.2, 220);
632
- return nearTop && wideEnough && shortEnough;
633
  }
634
 
635
- function buildSyntheticGroup(tag, children) {
636
- const rect = unionRects(children.map((child) => child.rect).filter(Boolean));
637
- const maxZ = Math.max(...children.map((child) => child.effectiveZ ?? 0), 0);
638
-
639
- return {
640
- tag,
641
- id: null,
642
- classList: [],
643
- _role: 'header',
644
- text: null,
645
- textRuns: [],
646
- isTextContainer: false,
647
- rect,
648
- computed: {
649
- display: 'block',
650
- position: 'static',
651
- zIndex: String(maxZ),
652
- flexDirection: 'row',
653
- justifyContent: 'flex-start',
654
- alignItems: 'stretch',
655
- flexWrap: 'nowrap',
656
- gap: '0px',
657
- columnGap: '0px',
658
- rowGap: '0px',
659
- gridTemplateColumns: 'none',
660
- gridTemplateRows: 'none',
661
- gridRow: 'auto',
662
- gridColumn: 'auto',
663
- width: `${rect.width}px`,
664
- height: `${rect.height}px`,
665
- minWidth: '0px',
666
- maxWidth: 'none',
667
- minHeight: '0px',
668
- paddingTop: '0px',
669
- paddingRight: '0px',
670
- paddingBottom: '0px',
671
- paddingLeft: '0px',
672
- marginTop: '0px',
673
- marginRight: '0px',
674
- marginBottom: '0px',
675
- marginLeft: '0px',
676
- backgroundColor: 'rgba(0, 0, 0, 0)',
677
- backgroundImage: 'none',
678
- backgroundSize: 'auto',
679
- backgroundPosition: '0% 0%',
680
- color: 'rgba(0, 0, 0, 0)',
681
- opacity: '1',
682
- borderRadius: '0px',
683
- borderTopLeftRadius: '0px',
684
- borderTopRightRadius: '0px',
685
- borderBottomRightRadius: '0px',
686
- borderBottomLeftRadius: '0px',
687
- border: '0px none rgba(0, 0, 0, 0)',
688
- borderWidth: '0px',
689
- borderColor: 'rgba(0, 0, 0, 0)',
690
- borderStyle: 'none',
691
- boxShadow: 'none',
692
- overflow: 'visible',
693
- overflowX: 'visible',
694
- overflowY: 'visible',
695
- mixBlendMode: 'normal',
696
- transform: 'none',
697
- fontFamily: 'Inter',
698
- fontSize: '16px',
699
- fontWeight: '400',
700
- fontStyle: 'normal',
701
- lineHeight: 'normal',
702
- letterSpacing: 'normal',
703
- textAlign: 'left',
704
- textTransform: 'none',
705
- whiteSpace: 'normal',
706
- textDecoration: 'none',
707
- webkitTextStrokeWidth: '0px',
708
- webkitTextStrokeColor: 'rgba(0, 0, 0, 0)',
709
- top: 'auto',
710
- right: 'auto',
711
- bottom: 'auto',
712
- left: 'auto',
713
- inset: 'auto',
714
- content: 'none',
715
- },
716
- pseudo: {
717
- before: null,
718
- after: null,
719
- },
720
- children,
721
- effectiveZ: maxZ,
722
- };
723
- }
724
-
725
- function unionRects(rects) {
726
- if (!Array.isArray(rects) || rects.length === 0) {
727
- return { x: 0, y: 0, width: 0, height: 0 };
728
- }
729
-
730
- const left = Math.min(...rects.map((rect) => rect.x));
731
- const top = Math.min(...rects.map((rect) => rect.y));
732
- const right = Math.max(...rects.map((rect) => rect.x + rect.width));
733
- const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
734
- return {
735
- x: left,
736
- y: top,
737
- width: Math.max(right - left, 0),
738
- height: Math.max(bottom - top, 0),
739
- };
740
- }
741
-
742
- function getRenderableGridStrategy(node, gridStrategy) {
743
- if (!node || !gridStrategy?.outerFrame || !Array.isArray(node.children) || node.children.length < 2) {
744
- return null;
745
- }
746
-
747
- const axis = detectLinearChildAxis(node.children);
748
- if (!axis) {
749
- return null;
750
- }
751
-
752
- return {
753
- ...gridStrategy.outerFrame,
754
- layoutMode: axis,
755
- itemSpacing: measureAxisSpacing(node.children, axis),
756
- };
757
- }
758
-
759
- function getRenderableInlineLayout(node) {
760
- if (!node?.computed || node.computed.display !== 'inline-block') {
761
- return null;
762
- }
763
-
764
- const children = Array.isArray(node.children) ? node.children.filter(Boolean) : [];
765
- if (children.length === 0) {
766
- return null;
767
- }
768
-
769
- if (children.some((child) => !child?.rect || isAbsoluteLikeNode(child))) {
770
- return null;
771
- }
772
-
773
- const detectedAxis = detectLinearChildAxis(children);
774
- if (detectedAxis === 'VERTICAL') {
775
- return null;
776
  }
777
 
778
- return {
779
- layoutMode: 'HORIZONTAL',
780
- primaryAxisAlignItems: 'MIN',
781
- counterAxisAlignItems: 'MIN',
782
- itemSpacing: measureAxisSpacing(children, 'HORIZONTAL'),
783
- };
784
  }
785
 
786
- function getRenderableFlexLayout(node) {
787
- if (!node?.computed) {
788
- return null;
789
- }
790
-
791
- const children = Array.isArray(node.children) ? node.children.filter(Boolean) : [];
792
- if (children.length === 0) {
793
- return mapFlexLayout(node.computed);
794
- }
795
-
796
- if (children.some((child) => !child?.rect || isAbsoluteLikeNode(child))) {
797
- return null;
798
- }
799
-
800
- const axis = isRowFlexDirection(node.computed.flexDirection) ? 'HORIZONTAL' : 'VERTICAL';
801
- const detectedAxis = detectLinearChildAxis(children);
802
- if (detectedAxis && detectedAxis !== axis) {
803
- return null;
804
- }
805
-
806
- if (hasSignificantFlexChildMargins(children, axis)) {
807
- return null;
808
  }
809
 
810
- const gaps = measureAxisGaps(children, axis);
811
- if (gaps.length > 1) {
812
- const minGap = Math.min(...gaps);
813
- const maxGap = Math.max(...gaps);
814
- const tolerance = Math.max(8, Math.round(Math.abs(minGap) * 0.25));
815
- if (maxGap - minGap > tolerance) {
816
- return null;
817
- }
818
  }
819
 
820
- return mapFlexLayout(node.computed);
821
- }
822
-
823
- function isRowFlexDirection(flexDirection) {
824
- return flexDirection !== 'column' && flexDirection !== 'column-reverse';
825
- }
826
-
827
- function isAbsoluteLikeNode(node) {
828
- const position = node?.computed?.position;
829
- return position === 'absolute' || position === 'fixed';
830
- }
831
-
832
- function hasSignificantFlexChildMargins(children, axis) {
833
- return children.some((child) => {
834
- const computed = child?.computed || {};
835
- if (axis === 'HORIZONTAL') {
836
- return Math.abs(parsePx(computed.marginLeft)) > 0.5 || Math.abs(parsePx(computed.marginRight)) > 0.5;
837
- }
838
-
839
- return Math.abs(parsePx(computed.marginTop)) > 0.5 || Math.abs(parsePx(computed.marginBottom)) > 0.5;
840
- });
841
- }
842
-
843
- function detectLinearChildAxis(children) {
844
- const tolerance = 8;
845
- const xs = groupAxisValues(children.map((child) => child.rect?.x ?? 0), tolerance);
846
- const ys = groupAxisValues(children.map((child) => child.rect?.y ?? 0), tolerance);
847
-
848
- if (ys.length === 1 && xs.length > 1) {
849
- return 'HORIZONTAL';
850
- }
851
- if (xs.length === 1 && ys.length > 1) {
852
- return 'VERTICAL';
853
- }
854
  return null;
855
  }
856
 
857
- function groupAxisValues(values, tolerance) {
858
- const sorted = [...values].sort((a, b) => a - b);
859
- const groups = [];
860
-
861
- for (const value of sorted) {
862
- const prev = groups[groups.length - 1];
863
- if (prev === undefined || Math.abs(value - prev) > tolerance) {
864
- groups.push(value);
865
- }
866
- }
867
-
868
- return groups;
869
- }
870
-
871
- function measureAxisGaps(children, axis) {
872
- const items = [...children]
873
- .filter((child) => child?.rect)
874
- .sort((a, b) => axis === 'HORIZONTAL' ? a.rect.x - b.rect.x : a.rect.y - b.rect.y);
875
-
876
- const gaps = [];
877
- for (let index = 1; index < items.length; index++) {
878
- const prev = items[index - 1].rect;
879
- const current = items[index].rect;
880
- const gap = axis === 'HORIZONTAL'
881
- ? current.x - (prev.x + prev.width)
882
- : current.y - (prev.y + prev.height);
883
- if (gap >= 0) {
884
- gaps.push(gap);
885
- }
886
- }
887
-
888
- return gaps;
889
- }
890
-
891
- function measureAxisSpacing(children, axis) {
892
- const gaps = measureAxisGaps(children, axis);
893
- let minGap = null;
894
- for (let index = 0; index < gaps.length; index++) {
895
- if (minGap === null || gaps[index] < minGap) {
896
- minGap = gaps[index];
897
- }
898
  }
899
 
900
- return Math.max(Math.round(minGap ?? 0), 0);
901
  }
902
 
903
- function findBestPseudoParent(node, pseudo) {
904
- let best = null;
905
-
906
- function walk(current, depth = 0) {
907
- if (!current || !current.rect || current.isTextContainer) return;
908
-
909
- const score = scorePseudoParent(current, pseudo, depth);
910
- if (score > 0 && (!best || score > best.score)) {
911
- best = { node: current, score };
912
- }
913
-
914
- for (const child of current.children || []) {
915
- walk(child, depth + 1);
916
- }
917
- }
918
-
919
- walk(node, 0);
920
- return best?.node ?? null;
921
- }
922
-
923
- function scorePseudoParent(node, pseudo, depth) {
924
- const rect = node.rect;
925
- if (!rect) return 0;
926
-
927
- const nodeArea = Math.max(rect.width * rect.height, 1);
928
- const pseudoArea = Math.max((pseudo.width || 0) * (pseudo.height || 0), 1);
929
- const contains =
930
- pseudo.x >= rect.x - 8 &&
931
- pseudo.y >= rect.y - 8 &&
932
- pseudo.x + pseudo.width <= rect.x + rect.width + 8 &&
933
- pseudo.y + pseudo.height <= rect.y + rect.height + 8;
934
- const intersects =
935
- pseudo.x < rect.x + rect.width &&
936
- pseudo.x + pseudo.width > rect.x &&
937
- pseudo.y < rect.y + rect.height &&
938
- pseudo.y + pseudo.height > rect.y;
939
-
940
- if (!contains && !intersects) {
941
- return 0;
942
- }
943
-
944
- const haystack = `${node.tag ?? ''} ${(node.classList || []).join(' ')} ${node.name ?? ''}`.toLowerCase();
945
- const tokens = String(pseudo.name || '')
946
- .toLowerCase()
947
- .split(/[^a-z0-9]+/g)
948
- .filter((token) => token.length > 2);
949
- let tokenHits = 0;
950
- for (const token of tokens) {
951
- if (haystack.includes(token)) tokenHits++;
952
- }
953
-
954
- if (tokenHits === 0 && depth > 0) {
955
- const nearSizedContainer = nodeArea <= pseudoArea * 64;
956
- if (!nearSizedContainer) {
957
- return 0;
958
- }
959
- }
960
-
961
- let score = tokenHits * 1000;
962
- if (contains) score += 500;
963
- else if (intersects) score += 120;
964
- score += Math.min(400, Math.round(100000 / nodeArea));
965
- score += Math.min(100, depth * 5);
966
- score += Math.min(80, Math.round(100000 / pseudoArea));
967
- return score;
968
- }
969
-
970
- function buildTextRuns(runs, fontMap) {
971
- return (runs || [])
972
- .filter((run) => run && run.text)
973
- .map((run) => ({
974
- text: run.text,
975
- lineIndex: run.lineIndex || 0,
976
- ...mapTypography(run.computed, fontMap),
977
- ...mapTextStroke(run.computed),
978
- }));
979
- }
980
-
981
- function mapFlexTextAlignment(computed) {
982
- if (!computed || (computed.display !== 'flex' && computed.display !== 'inline-flex')) {
983
- return {};
984
- }
985
-
986
- const isRow = computed.flexDirection !== 'column' && computed.flexDirection !== 'column-reverse';
987
- const primary = mapFlexTextAxisAlignment(computed.justifyContent, 'primary');
988
- const counter = mapFlexTextAxisAlignment(computed.alignItems, 'counter');
989
- const result = {};
990
-
991
- if (isRow) {
992
- if (primary.horizontal) result.textAlignHorizontal = primary.horizontal;
993
- if (counter.vertical) result.textAlignVertical = counter.vertical;
994
- } else {
995
- if (counter.horizontal) result.textAlignHorizontal = counter.horizontal;
996
- if (primary.vertical) result.textAlignVertical = primary.vertical;
997
  }
998
 
999
- return result;
1000
- }
1001
-
1002
- function mapFlexTextAxisAlignment(value, axisRole) {
1003
- const normalized = String(value || '').toLowerCase();
1004
- const horizontalMap = {
1005
- center: 'CENTER',
1006
- 'flex-start': 'LEFT',
1007
- start: 'LEFT',
1008
- left: 'LEFT',
1009
- 'flex-end': 'RIGHT',
1010
- end: 'RIGHT',
1011
- right: 'RIGHT',
1012
- };
1013
- const verticalMap = {
1014
- center: 'CENTER',
1015
- 'flex-start': 'TOP',
1016
- start: 'TOP',
1017
- 'flex-end': 'BOTTOM',
1018
- end: 'BOTTOM',
1019
- };
1020
 
1021
  return {
1022
- horizontal: horizontalMap[normalized] || null,
1023
- vertical: verticalMap[normalized] || null,
1024
- axisRole,
 
1025
  };
1026
  }
1027
 
1028
- function detectBackgroundPattern(computed) {
1029
- const backgroundImage = computed.backgroundImage || '';
1030
- const backgroundSize = computed.backgroundSize || '';
1031
- if (!backgroundImage.includes('linear-gradient') || !backgroundSize.includes('px')) {
1032
- return null;
1033
  }
1034
 
1035
- const gradientCount = (backgroundImage.match(/linear-gradient\(/g) || []).length;
1036
- if (gradientCount < 2) {
1037
- return null;
1038
- }
1039
-
1040
- const sizeMatch = backgroundSize.match(/([\d.]+)px\s+([\d.]+)px/);
1041
- const colorMatch = backgroundImage.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/);
1042
- if (!sizeMatch || !colorMatch) {
1043
- return null;
1044
- }
1045
 
1046
  return {
1047
- kind: 'grid',
1048
- cellWidth: Math.max(Math.round(parseFloat(sizeMatch[1])), 1),
1049
- cellHeight: Math.max(Math.round(parseFloat(sizeMatch[2])), 1),
1050
- strokeWeight: 1,
1051
- paint: colorSolidPaint(colorMatch[0]),
1052
  };
1053
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * src/figma/mapper.js
3
+ * Converts the annotated DOM tree (with z-index) into
4
+ * a Figma node tree JSON that the Figma plugin can execute.
5
+ *
6
+ * Output format: array of FigmaNode instructions
7
+ * that the plugin reads and calls figma.create* for each.
8
+ */
9
+
10
+ import {
11
+ mapFlexLayout,
12
+ mapPadding,
13
+ mapOverflow,
14
+ mapBorderRadius,
15
+ mapBackgroundColor,
16
+ mapBorder,
17
  mapBoxShadow,
18
  mapTypography,
19
  mapTextStroke,
20
+ shouldTruncateText,
21
  parseLinearGradient,
22
  parseLinearGradientLayers,
23
  } from './css-to-figma.js';
24
+ import { cssColorToFigma, solidPaint as colorSolidPaint } from '../utils/color.js';
25
+ import { parsePx } from '../utils/units.js';
26
+
27
+ /**
28
+ * @param {{ annotated: object, sortedFlat: object[] }} sorted
29
+ * @param {{ pseudoElements, gridStrategies, hoverSpecs, fontMap }} extras
30
+ * @returns {FigmaNode[]}
31
+ */
32
  export function buildFigmaTree({ annotated }, { pseudoElements = [], gridStrategies = {}, hoverSpecs = {}, fontMap = {} } = {}) {
33
  attachPseudoElements(annotated, pseudoElements);
34
  const normalizedRoot = normalizeRootStructure(annotated);
35
 
36
  // Build the main node tree
37
+ return [buildNode(normalizedRoot, null, { fontMap, gridStrategies, hoverSpecs, surfaceFills: [] }, '0')];
38
  }
39
+
40
  function buildNode(node, parentContext, ctx, path) {
41
+ const { computed, rect, tag, text, textRuns = [], children = [], classList, isTextContainer, _pageLayout, _role, svgMarkup, imageData } = node;
42
+ const rawResolvedRect = resolveRenderedRect(node, parentContext);
43
  const parentResolvedRect = parentContext?.resolvedRect ?? null;
44
  const isLeafText = Boolean(text) && children.length === 0;
45
  const isText = isLeafText && Boolean(isTextContainer);
46
+ const usesTableCellAutoWidth = Boolean(parentContext?.tableCellAutoWidth) || isTableCellNode(node) || isTableCellNode(parentContext?.sourceNode);
47
+ const inheritedTextTruncationContext = usesTableCellAutoWidth ? null : getInheritedTextTruncationContext(parentContext);
48
+ const resolvedRect = isText && inheritedTextTruncationContext
49
+ ? clampRectToTextTruncationContext(rawResolvedRect, inheritedTextTruncationContext)
50
+ : rawResolvedRect;
51
  const isSvg = tag === 'svg' && Boolean(svgMarkup);
52
+ const isImage = Boolean(imageData?.src) && (tag === 'img' || tag === 'canvas');
53
+ const isAbsolute = isAbsoluteLikeNode(node) || node._layoutPositioning === 'ABSOLUTE';
54
+ const childLayoutSizing = mapChildLayoutSizing(node, parentContext, resolvedRect);
55
+
56
+ const base = {
57
+ id: buildStableId(tag, classList, path),
58
+ name: buildName(tag, classList),
59
+ type: isSvg ? 'SVG' : isImage ? 'IMAGE' : (isText && text ? 'TEXT' : 'FRAME'),
60
+ x: Math.round(resolvedRect.x - (parentResolvedRect?.x ?? 0)),
61
+ y: Math.round(resolvedRect.y - (parentResolvedRect?.y ?? 0)),
62
+ width: Math.round(resolvedRect.width),
63
+ height: Math.round(resolvedRect.height),
64
+ ...(isAbsolute ? { layoutPositioning: 'ABSOLUTE' } : {}),
65
+ ...childLayoutSizing,
66
+ };
67
+
68
+ if (isSvg) {
69
+ return {
70
+ ...base,
71
+ _svgMarkup: svgMarkup,
72
+ opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
73
+ ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? {
74
+ blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'),
75
+ } : {}),
76
+ };
77
+ }
78
+
79
+ if (isImage) {
80
+ return {
81
+ ...base,
82
+ _image: imageData,
83
+ opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
84
+ ...mapBorderRadius(computed, rect),
85
+ ...mapBorder(computed),
86
+ effects: mapBoxShadow(computed),
87
+ ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? {
88
+ blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'),
89
+ } : {}),
90
+ ...(computed.objectFit ? { _objectFit: computed.objectFit } : {}),
91
+ ...(computed.objectPosition ? { _objectPosition: computed.objectPosition } : {}),
92
+ };
93
  }
94
 
95
  if (base.type === 'TEXT') {
96
+ const typography = mapTypography(computed, ctx.fontMap, parentContext?.sourceNode?.computed);
97
+ if (usesTableCellAutoWidth) {
98
+ forceAutoWidthTableCellText(typography);
99
+ } else if (inheritedTextTruncationContext && !typography.textTruncation) {
100
+ typography.textTruncation = 'ENDING';
101
+ }
102
+
103
  return {
104
  ...base,
105
  characters: text,
106
+ ...typography,
107
  ...mapFlexTextAlignment(computed),
108
  ...mapTextStroke(computed),
109
  textRuns: buildTextRuns(textRuns, ctx.fontMap),
110
  opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
111
+ };
112
+ }
113
+
114
+ // Frame node
115
+ const isGrid = computed.display === 'grid';
116
+ const isFlex = computed.display === 'flex' || computed.display === 'inline-flex';
117
+ const isInlineBlock = computed.display === 'inline-block';
118
+ const flexLayoutInfo = isFlex ? getRenderableFlexLayout(node) : null;
119
+
120
+ const layout = isFlex
121
+ ? flexLayoutInfo?.layout
122
+ : isInlineBlock
123
+ ? getRenderableInlineLayout(node)
124
+ : null;
125
+
126
+ // Check if a grid strategy was provided for this element
127
+ const gridClass = classList?.find(c => ctx.gridStrategies?.[`.${c}`]);
128
+ const gridStrategy = gridClass ? ctx.gridStrategies[`.${gridClass}`] : null;
129
+
130
+ // Check hover spec
131
+ const hoverClass = classList?.find(c => ctx.hoverSpecs?.[`.${c}`]);
132
+ const hoverSpec = hoverClass ? ctx.hoverSpecs[`.${hoverClass}`] : null;
133
+
134
  // Background fills
135
+ let fills = mapBackgroundColor(computed);
136
  const backgroundPattern = detectBackgroundPattern(computed);
137
 
138
  // Handle linear-gradient in backgroundImage
 
142
  } catch { /* skip malformed gradients */ }
143
  }
144
 
145
+ if (fills.length === 0 && isPaginationNode(node) && Array.isArray(parentContext?.surfaceFills) && parentContext.surfaceFills.length > 0) {
146
+ fills = clonePaints(parentContext.surfaceFills);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  }
148
 
149
+ const nextSurfaceFills = fills.length > 0 ? clonePaints(fills) : (parentContext?.surfaceFills || []);
 
 
 
 
 
 
150
 
151
+ const frameNode = {
152
+ ...base,
153
+ ...(_pageLayout ? { _pageLayout: true } : {}),
154
+ ...(_role ? { _role } : {}),
155
+ fills,
156
+ ...mapPadding(computed),
157
+ ...mapOverflow(computed),
158
+ ...mapBorderRadius(computed, rect),
159
+ ...mapBorder(computed),
160
+ effects: mapBoxShadow(computed),
161
+ opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
162
+ ...(layout || {}),
163
+ ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? {
164
+ blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'),
165
+ } : {}),
166
+ };
167
+
168
+ if (_pageLayout || tag === 'body') {
169
+ frameNode.clipsContent = true;
170
+ }
171
+
172
+ // Apply grid strategy when a renderable fallback is available
173
+ const renderableGridStrategy = isGrid ? getRenderableGridStrategy(node, gridStrategy) : null;
174
+ if (renderableGridStrategy) {
175
+ frameNode._gridStrategy = renderableGridStrategy;
176
+ frameNode._gridNotes = gridStrategy.notes;
177
+ }
178
+
179
+ // Attach hover spec for Figma plugin to create variants
180
+ if (hoverSpec) {
181
+ frameNode._hoverSpec = hoverSpec;
182
+ }
183
+ if (backgroundPattern) {
184
+ frameNode._backgroundPattern = backgroundPattern;
185
+ }
186
+
187
  // Recurse
188
  const childNodes = [];
189
+ const childTextTruncationContext = usesTableCellAutoWidth ? null : getChildTextTruncationContext(node, resolvedRect, inheritedTextTruncationContext);
190
 
191
  if (isLeafText) {
192
+ childNodes.push(buildEmbeddedTextNode(node, ctx, `${path}.text`, resolvedRect, 'text', childTextTruncationContext, usesTableCellAutoWidth));
193
+ }
194
+
195
+ const controlTextNode = buildFormControlTextNode(node, ctx, `${path}.control`, resolvedRect, childTextTruncationContext, usesTableCellAutoWidth);
196
+ if (controlTextNode) {
197
+ childNodes.push(controlTextNode);
198
+ }
199
+
200
+ const pseudoChildren = (node.pseudoChildren || []).concat(getNativePseudoChildren(node));
201
+ const mergeablePseudoBackgrounds = [];
202
+ const renderablePseudoChildren = [];
203
+
204
+ for (const pseudo of pseudoChildren) {
205
+ if (shouldMergePseudoIntoParent(node, pseudo)) {
206
+ mergeablePseudoBackgrounds.push(...buildMergedPseudoBackgrounds(pseudo));
207
+ continue;
208
+ }
209
+ renderablePseudoChildren.push(pseudo);
210
+ }
211
+
212
+ const pseudoBefore = renderablePseudoChildren
213
+ .filter((pseudo) => pseudo.zOrder !== 'top')
214
+ .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudo.${index}`, ctx))
215
+ .filter(Boolean);
216
+ const pseudoTop = renderablePseudoChildren
217
+ .filter((pseudo) => pseudo.zOrder === 'top')
218
+ .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudoTop.${index}`, ctx))
219
+ .filter(Boolean);
220
+
221
+ frameNode.children = pseudoBefore
222
+ .concat(childNodes)
223
+ .concat(
224
+ getOrderedChildren(children)
225
+ .map((child, index) => buildNode(child, {
226
+ sourceRect: rect,
227
+ resolvedRect,
228
+ sourceNode: node,
229
+ textTruncationContext: childTextTruncationContext,
230
+ tableCellAutoWidth: usesTableCellAutoWidth,
231
+ surfaceFills: nextSurfaceFills,
232
+ }, ctx, `${path}.${index}`))
233
  .filter(Boolean)
234
  )
235
  .concat(pseudoTop);
236
+
237
+ if (mergeablePseudoBackgrounds.length > 0) {
238
+ frameNode.fills = frameNode.fills.concat(mergeablePseudoBackgrounds);
239
+ }
240
+
241
+ return frameNode;
242
+ }
243
+
244
+ function mapChildLayoutSizing(node, parentContext, resolvedRect) {
245
+ const parentNode = parentContext?.sourceNode;
246
+ const parentComputed = parentNode?.computed;
247
+ if (!node || !resolvedRect || !parentContext?.resolvedRect || !isFlexDisplay(parentComputed?.display) || isAbsoluteLikeNode(node)) {
248
+ return {};
249
+ }
250
+
251
+ const result = {};
252
+ const parentRect = parentContext.resolvedRect;
253
+ const parentInnerWidth = Math.max(parentRect.width - parsePx(parentComputed.paddingLeft) - parsePx(parentComputed.paddingRight), 0);
254
+ const parentInnerHeight = Math.max(parentRect.height - parsePx(parentComputed.paddingTop) - parsePx(parentComputed.paddingBottom), 0);
255
+ const axis = isRowFlexDirection(parentComputed.flexDirection) ? 'HORIZONTAL' : 'VERTICAL';
256
+ const flexGrow = parseFloat(node.computed?.flexGrow);
257
+
258
+ if (axis === 'VERTICAL' && fillsAxis(resolvedRect.width, parentInnerWidth)) {
259
+ result.layoutSizingHorizontal = 'FILL';
260
+ }
261
+ if (axis === 'HORIZONTAL' && fillsAxis(resolvedRect.height, parentInnerHeight)) {
262
+ result.layoutSizingVertical = 'FILL';
263
+ }
264
+
265
+ if (Number.isFinite(flexGrow) && flexGrow > 0) {
266
+ if (axis === 'HORIZONTAL') {
267
+ if (!shouldHugSingleTextFlexChild(parentNode, node, axis)) {
268
+ result.layoutSizingHorizontal = 'FILL';
269
+ }
270
+ } else {
271
+ result.layoutSizingVertical = 'FILL';
272
+ }
273
+ }
274
+
275
+ return result;
276
+ }
277
+
278
+ function fillsAxis(childSize, parentInnerSize) {
279
+ if (!Number.isFinite(childSize) || !Number.isFinite(parentInnerSize) || parentInnerSize <= 0) {
280
+ return false;
281
+ }
282
+
283
+ return Math.abs(childSize - parentInnerSize) <= Math.max(2, parentInnerSize * 0.02);
284
+ }
285
+
286
+ function resolveRenderedRect(node, parentContext) {
287
+ const sourceRect = node?.rect || { x: 0, y: 0, width: 0, height: 0 };
288
+ if (!parentContext?.sourceRect || !parentContext?.resolvedRect) {
289
+ return sourceRect;
290
+ }
291
+
292
+ const resolved = reprojectRectWithinParent(sourceRect, parentContext.sourceRect, parentContext.resolvedRect);
293
+ if (shouldStretchAspectWrapper(node, parentContext)) {
294
+ return {
295
+ ...resolved,
296
+ width: parentContext.resolvedRect.width,
297
+ height: parentContext.resolvedRect.height,
298
+ x: parentContext.resolvedRect.x + (sourceRect.x - parentContext.sourceRect.x),
299
+ y: parentContext.resolvedRect.y + (sourceRect.y - parentContext.sourceRect.y),
300
+ };
301
+ }
302
+
303
+ return resolved;
304
+ }
305
+
306
+ function reprojectRectWithinParent(childRect, sourceParentRect, resolvedParentRect) {
307
+ const rect = childRect || { x: 0, y: 0, width: 0, height: 0 };
308
+ const sourceParent = sourceParentRect || { x: 0, y: 0, width: 0, height: 0 };
309
+ const resolvedParent = resolvedParentRect || sourceParent;
310
+ const tolerance = 1.5;
311
+
312
+ if (isSameRect(sourceParent, resolvedParent)) {
313
+ return rect;
314
+ }
315
+
316
+ const leftOffset = (rect.x ?? 0) - (sourceParent.x ?? 0);
317
+ const topOffset = (rect.y ?? 0) - (sourceParent.y ?? 0);
318
+ const rightOffset = (sourceParent.x ?? 0) + (sourceParent.width ?? 0) - ((rect.x ?? 0) + (rect.width ?? 0));
319
+ const bottomOffset = (sourceParent.y ?? 0) + (sourceParent.height ?? 0) - ((rect.y ?? 0) + (rect.height ?? 0));
320
+
321
+ const fillsHorizontal = isClose(leftOffset, 0, tolerance)
322
+ && isClose(rightOffset, 0, tolerance)
323
+ && isClose(rect.width ?? 0, sourceParent.width ?? 0, tolerance);
324
+ const fillsVertical = isClose(topOffset, 0, tolerance)
325
+ && isClose(bottomOffset, 0, tolerance)
326
+ && isClose(rect.height ?? 0, sourceParent.height ?? 0, tolerance);
327
+
328
+ const width = fillsHorizontal ? resolvedParent.width : rect.width;
329
+ const height = fillsVertical ? resolvedParent.height : rect.height;
330
+
331
+ const x = fillsHorizontal
332
+ ? resolvedParent.x + leftOffset
333
+ : (rightOffset < leftOffset
334
+ ? resolvedParent.x + resolvedParent.width - rightOffset - width
335
+ : resolvedParent.x + leftOffset);
336
+
337
+ const y = fillsVertical
338
+ ? resolvedParent.y + topOffset
339
+ : (bottomOffset < topOffset
340
+ ? resolvedParent.y + resolvedParent.height - bottomOffset - height
341
+ : resolvedParent.y + topOffset);
342
+
343
+ return {
344
+ x,
345
+ y,
346
+ width,
347
+ height,
348
+ };
349
+ }
350
+
351
+ function shouldStretchAspectWrapper(node, parentContext) {
352
+ if (!node?.rect || !parentContext?.sourceRect || !parentContext?.resolvedRect) {
353
+ return false;
354
+ }
355
+
356
+ if (node.computed?.position === 'absolute' || node.computed?.position === 'fixed') {
357
+ return false;
358
+ }
359
+
360
+ if (parsePx(node.computed?.paddingBottom) <= 0) {
361
+ return false;
362
+ }
363
+
364
+ if (!Array.isArray(node.children) || node.children.length === 0) {
365
+ return false;
366
+ }
367
+
368
+ if (node.children.some((child) => !isAbsoluteLikeNode(child))) {
369
+ return false;
370
+ }
371
+
372
+ if (node.pseudoChildren?.length > 0 || node?.pseudo?.before || node?.pseudo?.after) {
373
+ return false;
374
+ }
375
+
376
+ const sourceRect = node.rect;
377
+ const parentRect = parentContext.sourceRect;
378
+ const widthMatches = isClose(sourceRect.width, parentRect.width, 2);
379
+ const xMatches = isClose(sourceRect.x, parentRect.x, 2);
380
+ const yMatches = isClose(sourceRect.y, parentRect.y, 2);
381
+ const isShorter = sourceRect.height + 2 < parentRect.height;
382
+
383
+ return widthMatches && xMatches && yMatches && isShorter;
384
+ }
385
+
386
+ function isClose(a, b, tolerance = 1.5) {
387
+ return Math.abs((a ?? 0) - (b ?? 0)) <= tolerance;
388
+ }
389
+
390
+ function isSameRect(a, b, tolerance = 0.01) {
391
+ return isClose(a?.x, b?.x, tolerance)
392
+ && isClose(a?.y, b?.y, tolerance)
393
+ && isClose(a?.width, b?.width, tolerance)
394
+ && isClose(a?.height, b?.height, tolerance);
395
+ }
396
+
397
+ function getNativePseudoChildren(node) {
398
+ const result = [];
399
+ const pseudo = node?.pseudo || {};
400
+ const rect = node?.rect || { x: 0, y: 0 };
401
+
402
+ for (const type of ['before', 'after']) {
403
+ const entry = pseudo[type];
404
+ if (!entry?.rect) continue;
405
+
406
+ result.push({
407
+ ...entry,
408
+ x: entry.rect.x - rect.x,
409
+ y: entry.rect.y - rect.y,
410
+ width: entry.rect.width,
411
+ height: entry.rect.height,
412
+ zOrder: entry.zOrder || (type === 'before' ? 'bottom' : 'top'),
413
+ });
414
+ }
415
+
416
+ return result;
417
+ }
418
+
419
+ function buildPseudoNode(pseudo, path, ctx = {}) {
420
+ const pseudoId = `pseudo-${path}-${pseudo.name.replace(/\s+/g, '-').toLowerCase()}`;
421
+ const isTextPseudo = pseudo.type === 'text' && Boolean(pseudo.content);
422
+ const pseudoBackgrounds = isTextPseudo ? [] : buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor);
423
+ const pseudoEffects = pseudo.computed ? mapBoxShadow(pseudo.computed) : [];
424
+ const pseudoStrokes = pseudo.computed ? mapBorder(pseudo.computed) : {};
425
+ const textTypography = pseudo.computed
426
+ ? {
427
+ ...mapTypography(pseudo.computed, ctx.fontMap),
428
+ ...mapTextStroke(pseudo.computed),
429
+ }
430
+ : {
431
+ fontName: {
432
+ family: 'Inter',
433
+ style: 'Regular',
434
+ },
435
+ fontSize: Math.max(Math.min(Math.round(pseudo.height || 16), 48), 12),
436
+ fills: pseudo.fillColor && pseudo.fillColor !== 'noise-texture'
437
+ ? [colorSolidPaint(pseudo.fillColor)]
438
+ : [colorSolidPaint('#ffffff')],
439
+ };
440
+
441
+ return {
442
+ id: pseudoId,
443
+ name: `[pseudo] ${pseudo.name}`,
444
+ type: 'FRAME',
445
+ x: Math.round(pseudo.x),
446
+ y: Math.round(pseudo.y),
447
+ width: Math.round(pseudo.width),
448
+ height: Math.round(pseudo.height),
449
+ layoutPositioning: 'ABSOLUTE',
450
+ opacity: roundFloat(pseudo.opacity ?? 1),
451
+ fills: pseudoBackgrounds,
452
+ ...pseudoStrokes,
453
+ effects: pseudoEffects,
454
+ _isPseudo: true,
455
+ _pseudoType: pseudo.type,
456
+ _pseudoPosition: pseudo.position,
457
+ children: pseudo.content ? [{
458
+ id: `${pseudoId}-content`,
459
+ name: 'content',
460
+ type: 'TEXT',
461
+ characters: pseudo.content,
462
+ x: 0, y: 0,
463
+ width: pseudo.width,
464
+ height: pseudo.height,
465
+ ...textTypography,
466
+ }] : [],
467
+ };
468
+ }
469
+
470
+ function buildFormControlTextNode(node, ctx, path, resolvedRect = null, textTruncationContext = null, tableCellAutoWidth = false) {
471
+ const rendered = resolveFormControlText(node.formControl);
472
+ if (!rendered) {
473
+ return null;
474
+ }
475
+
476
+ const computed = rendered.kind === 'placeholder'
477
+ ? mergeFormControlTextStyles(node.computed, node.formControl?.placeholderComputed)
478
+ : node.computed;
479
+
480
+ return buildEmbeddedTextNode(
481
+ {
482
+ ...node,
483
+ text: rendered.text,
484
+ textRuns: [{
485
+ text: rendered.text,
486
+ lineIndex: 0,
487
+ computed,
488
+ }],
489
+ computed,
490
+ },
491
  ctx,
492
  path,
493
  resolvedRect,
494
+ rendered.kind,
495
+ textTruncationContext,
496
+ tableCellAutoWidth
497
  );
498
  }
499
+
500
+ function resolveFormControlText(formControl) {
501
+ if (!formControl) {
502
+ return null;
503
+ }
504
+
505
+ const value = normalizeControlText(formControl.value);
506
+ if (value) {
507
+ return { kind: 'value', text: value };
508
+ }
509
+
510
+ const placeholder = normalizeControlText(formControl.placeholder);
511
+ if (placeholder) {
512
+ return { kind: 'placeholder', text: placeholder };
513
+ }
514
+
515
+ return null;
516
+ }
517
+
518
+ function normalizeControlText(value) {
519
+ return String(value || '').replace(/\r/g, '').trim();
520
+ }
521
+
522
+ function mergeFormControlTextStyles(baseComputed, overrideComputed) {
523
+ if (!overrideComputed) {
524
+ return baseComputed;
525
+ }
526
+
527
+ const merged = { ...baseComputed };
528
+ const textKeys = [
529
+ 'fontFamily',
530
+ 'fontSize',
531
+ 'fontWeight',
532
+ 'fontStyle',
533
+ 'lineHeight',
534
+ 'letterSpacing',
535
+ 'textAlign',
536
+ 'textTransform',
537
+ 'color',
538
+ 'opacity',
539
+ 'textDecoration',
540
+ 'webkitTextStrokeWidth',
541
+ 'webkitTextStrokeColor',
542
+ ];
543
+
544
+ for (const key of textKeys) {
545
+ if (overrideComputed[key] !== undefined && overrideComputed[key] !== null && overrideComputed[key] !== '') {
546
+ merged[key] = overrideComputed[key];
547
+ }
548
+ }
549
+
550
+ return merged;
551
+ }
552
+
553
+ function buildPseudoBackgrounds(computed, fallbackFillColor) {
554
+ if (!computed) {
555
+ return fallbackFillColor && fallbackFillColor !== 'noise-texture'
556
+ ? [colorSolidPaint(fallbackFillColor)]
557
+ : [];
558
+ }
559
+
560
+ const fills = mapBackgroundColor(computed);
561
+ if (computed.backgroundImage && computed.backgroundImage.includes('linear-gradient')) {
562
+ fills.push(...parseLinearGradientLayers(computed.backgroundImage));
563
+ }
564
+
565
+ if (fills.length === 0 && fallbackFillColor && fallbackFillColor !== 'noise-texture') {
566
+ fills.push(colorSolidPaint(fallbackFillColor));
567
+ }
568
+
569
+ return fills;
570
+ }
571
+
572
+ function buildMergedPseudoBackgrounds(pseudo) {
573
+ const paints = buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor);
574
+ const opacity = Number.isFinite(pseudo.opacity) ? pseudo.opacity : 1;
575
+ return paints.map((paint) => applyPaintOpacity(paint, opacity));
576
+ }
577
+
578
+ function shouldMergePseudoIntoParent(node, pseudo) {
579
+ if (!node?.computed || !pseudo || pseudo.type === 'text' || pseudo.zOrder !== 'bottom') {
580
+ return false;
581
+ }
582
+
583
+ const position = pseudo.position;
584
+ if (position !== 'absolute' && position !== 'fixed') {
585
+ return false;
586
+ }
587
+
588
+ if (!isTransparentCssBackground(node.computed) || !pseudo.rect || !node.rect) {
589
+ return false;
590
+ }
591
+
592
+ const parent = node.rect;
593
+ const child = pseudo.rect;
594
+ const tolerance = 1.5;
595
+ const coversParent =
596
+ Math.abs((child.x ?? 0) - (parent.x ?? 0)) <= tolerance &&
597
+ Math.abs((child.y ?? 0) - (parent.y ?? 0)) <= tolerance &&
598
+ Math.abs((child.width ?? 0) - (parent.width ?? 0)) <= tolerance &&
599
+ Math.abs((child.height ?? 0) - (parent.height ?? 0)) <= tolerance;
600
+
601
+ if (!coversParent) {
602
+ return false;
603
+ }
604
+
605
+ return buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor).length > 0;
606
+ }
607
+
608
+ function isTransparentCssBackground(computed) {
609
+ const backgroundColor = computed?.backgroundColor || '';
610
+ const backgroundImage = computed?.backgroundImage || '';
611
+ return isTransparentCssColor(backgroundColor) && backgroundImage === 'none';
612
+ }
613
+
614
+ function isTransparentCssColor(value) {
615
+ if (!value || value === 'transparent' || value === 'none') {
616
+ return true;
617
+ }
618
+ return cssColorToFigma(value).a === 0;
619
+ }
620
+
621
  function applyPaintOpacity(paint, opacity) {
622
  if (!paint || opacity === 1 || !Number.isFinite(opacity)) {
623
  return paint;
624
  }
625
+
626
+ const copy = JSON.parse(JSON.stringify(paint));
627
+ const existing = Number.isFinite(copy.opacity) ? copy.opacity : 1;
628
  copy.opacity = existing * opacity;
629
  return copy;
630
  }
631
 
632
+ function clonePaints(paints) {
633
+ return (paints || []).map((paint) => JSON.parse(JSON.stringify(paint)));
 
634
  }
635
 
636
+ function isPaginationNode(node) {
637
+ if (!node) {
638
+ return false;
639
+ }
 
 
 
 
 
640
 
641
+ const haystack = `${node.tag || ''} ${(node.classList || []).join(' ')} ${node.id || ''} ${node.name || ''}`.toLowerCase();
642
+ return /(?:^|\s)(pagination|paginator|pager|page-nav|page-control)(?:\s|$)/.test(haystack)
643
+ || /pagination|paginator|pager|page-nav|page-control/.test(haystack);
644
  }
645
+
646
+ function buildName(tag, classList) {
647
+ if (classList?.length > 0) return `${tag}.${classList.slice(0, 2).join('.')}`;
648
+ return tag;
649
+ }
650
+
651
+ function buildStableId(tag, classList, path) {
652
+ const slug = (classList?.slice(0, 2).join('-') || 'el')
653
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
654
+ .replace(/-+/g, '-')
655
+ .replace(/^-|-$/g, '')
656
+ || 'el';
657
+
658
+ return `${tag}-${slug}-${path.replace(/\./g, '-')}`;
659
+ }
660
+
661
+ function roundFloat(value, precision = 4) {
662
+ const factor = 10 ** precision;
663
+ return Math.round((value + Number.EPSILON) * factor) / factor;
664
+ }
665
+
666
+ function buildEmbeddedTextNode(node, ctx, path, resolvedRect = null, nameSuffix = 'text', textTruncationContext = null, tableCellAutoWidth = false) {
667
  const { computed, rect, tag, text, textRuns = [], classList } = node;
668
  const insetX = parsePx(computed.paddingLeft);
669
  const insetY = parsePx(computed.paddingTop);
670
  const sourceRect = resolvedRect || rect;
671
+ const initialTextRect = {
672
+ x: sourceRect.x + insetX,
673
+ y: sourceRect.y + insetY,
674
+ width: Math.max(sourceRect.width - insetX - parsePx(computed.paddingRight), 1),
675
+ height: Math.max(sourceRect.height - insetY - parsePx(computed.paddingBottom), 1),
676
+ };
677
+ const textRect = textTruncationContext
678
+ ? clampRectToTextTruncationContext(initialTextRect, textTruncationContext)
679
+ : initialTextRect;
680
+ const typography = mapTypography(computed, ctx.fontMap, node.computed);
681
+ if (tableCellAutoWidth) {
682
+ forceAutoWidthTableCellText(typography);
683
+ } else if (textTruncationContext && !typography.textTruncation) {
684
+ typography.textTruncation = 'ENDING';
685
+ }
686
 
687
  return {
688
  id: buildStableId(tag, classList, `${path}-inner`),
689
  name: `${buildName(tag, classList)} / ${nameSuffix}`,
690
  type: 'TEXT',
691
+ x: Math.round(textRect.x - sourceRect.x),
692
+ y: Math.round(textRect.y - sourceRect.y),
693
+ width: Math.max(Math.round(textRect.width), 1),
694
+ height: Math.max(Math.round(textRect.height), 1),
695
  characters: text,
696
+ ...typography,
697
  ...mapFlexTextAlignment(computed),
698
  ...mapTextStroke(computed),
699
  textRuns: buildTextRuns(textRuns, ctx.fontMap),
700
  };
701
  }
702
 
703
+ function forceAutoWidthTableCellText(typography) {
704
+ if (!typography) {
705
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  }
707
 
708
+ delete typography.textTruncation;
709
+ typography.whiteSpace = 'nowrap';
 
 
710
  }
711
 
712
+ function isTableCellNode(node) {
713
+ const tag = String(node?.tag || '').toLowerCase();
714
+ if (tag === 'td' || tag === 'th') {
715
+ return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  }
717
 
718
+ return String(node?.computed?.display || '').toLowerCase() === 'table-cell';
 
 
 
 
 
719
  }
720
 
721
+ function getInheritedTextTruncationContext(parentContext) {
722
+ if (parentContext?.textTruncationContext) {
723
+ return parentContext.textTruncationContext;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
  }
725
 
726
+ if (parentContext?.sourceNode && shouldTruncateText(parentContext.sourceNode.computed, null)) {
727
+ return createTextTruncationContext(parentContext.resolvedRect, parentContext.sourceNode.computed);
 
 
 
 
 
 
728
  }
729
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  return null;
731
  }
732
 
733
+ function getChildTextTruncationContext(node, resolvedRect, inheritedContext) {
734
+ if (node && shouldTruncateText(node.computed, null)) {
735
+ return createTextTruncationContext(resolvedRect, node.computed);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
  }
737
 
738
+ return inheritedContext;
739
  }
740
 
741
+ function createTextTruncationContext(rect, computed = {}) {
742
+ if (!rect) {
743
+ return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
744
  }
745
 
746
+ const left = rect.x + parsePx(computed.paddingLeft);
747
+ const right = rect.x + rect.width - parsePx(computed.paddingRight);
748
+ const top = rect.y + parsePx(computed.paddingTop);
749
+ const bottom = rect.y + rect.height - parsePx(computed.paddingBottom);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
 
751
  return {
752
+ left,
753
+ right: Math.max(right, left + 1),
754
+ top,
755
+ bottom: Math.max(bottom, top + 1),
756
  };
757
  }
758
 
759
+ function clampRectToTextTruncationContext(rect, context) {
760
+ if (!rect || !context) {
761
+ return rect;
 
 
762
  }
763
 
764
+ const left = Math.max(rect.x, context.left);
765
+ const right = Math.min(rect.x + rect.width, context.right);
766
+ const width = Math.max(right - left, 1);
 
 
 
 
 
 
 
767
 
768
  return {
769
+ ...rect,
770
+ x: left,
771
+ width,
 
 
772
  };
773
  }
774
+
775
+ function getOrderedChildren(children) {
776
+ const items = (children || [])
777
+ .filter(Boolean)
778
+ .map((child, index) => ({
779
+ child,
780
+ index,
781
+ layerZ: getLayerZ(child),
782
+ }));
783
+
784
+ if (items.length <= 1) {
785
+ return items.map((item) => item.child);
786
+ }
787
+
788
+ const hasLayering = items.some((item) => Number.isFinite(item.layerZ));
789
+ if (!hasLayering) {
790
+ return items.map((item) => item.child);
791
+ }
792
+
793
+ return items
794
+ .sort((a, b) => {
795
+ const zA = Number.isFinite(a.layerZ) ? a.layerZ : 0;
796
+ const zB = Number.isFinite(b.layerZ) ? b.layerZ : 0;
797
+ if (zA !== zB) {
798
+ return zA - zB;
799
+ }
800
+ return a.index - b.index;
801
+ })
802
+ .map((item) => item.child);
803
+ }
804
+
805
+ function getLayerZ(node) {
806
+ if (!node) {
807
+ return null;
808
+ }
809
+
810
+ if (Number.isFinite(node.effectiveZ)) {
811
+ return node.effectiveZ;
812
+ }
813
+
814
+ const zIndex = parseFloat(node.computed?.zIndex);
815
+ return Number.isFinite(zIndex) ? zIndex : null;
816
+ }
817
+
818
+ function attachPseudoElements(root, pseudoElements) {
819
+ if (!root || !Array.isArray(pseudoElements) || pseudoElements.length === 0) return;
820
+
821
+ for (const pseudo of pseudoElements) {
822
+ const target = findBestPseudoParent(root, pseudo) || root;
823
+ const relative = {
824
+ ...pseudo,
825
+ x: Math.round(pseudo.x - (target.rect?.x ?? 0)),
826
+ y: Math.round(pseudo.y - (target.rect?.y ?? 0)),
827
+ };
828
+ if (!target.pseudoChildren) {
829
+ target.pseudoChildren = [];
830
+ }
831
+ target.pseudoChildren.push(relative);
832
+ }
833
+ }
834
+
835
+ function normalizeRootStructure(root) {
836
+ if (!root || root.tag !== 'body' || !Array.isArray(root.children) || root.children.length === 0) {
837
+ return root;
838
+ }
839
+
840
+ const headerChildren = root.children.filter((child) => isTopHeaderChild(child, root.rect));
841
+ if (headerChildren.length === 0 || headerChildren.length === root.children.length) {
842
+ return {
843
+ ...root,
844
+ _pageLayout: true,
845
+ };
846
+ }
847
+
848
+ const otherChildren = root.children.filter((child) => !isTopHeaderChild(child, root.rect));
849
+ const syntheticHeader = buildSyntheticGroup('header', headerChildren);
850
+ return {
851
+ ...root,
852
+ _pageLayout: true,
853
+ children: [syntheticHeader].concat(otherChildren),
854
+ };
855
+ }
856
+
857
+ function isTopHeaderChild(node, rootRect) {
858
+ if (!node?.rect || !node?.computed) return false;
859
+
860
+ const position = node.computed.position;
861
+ if (position !== 'fixed' && position !== 'absolute') {
862
+ return false;
863
+ }
864
+
865
+ const nearTop = Math.abs((node.rect.y ?? 0) - (rootRect?.y ?? 0)) <= 8;
866
+ const wideEnough = (node.rect.width ?? 0) >= Math.max((rootRect?.width ?? 0) * 0.6, 320);
867
+ const shortEnough = (node.rect.height ?? 0) <= Math.max((rootRect?.height ?? 0) * 0.2, 220);
868
+ return nearTop && wideEnough && shortEnough;
869
+ }
870
+
871
+ function buildSyntheticGroup(tag, children) {
872
+ const rect = unionRects(children.map((child) => child.rect).filter(Boolean));
873
+ const maxZ = Math.max(...children.map((child) => child.effectiveZ ?? 0), 0);
874
+
875
+ return {
876
+ tag,
877
+ id: null,
878
+ classList: [],
879
+ _role: 'header',
880
+ text: null,
881
+ textRuns: [],
882
+ isTextContainer: false,
883
+ rect,
884
+ computed: {
885
+ display: 'block',
886
+ position: 'static',
887
+ zIndex: String(maxZ),
888
+ flexDirection: 'row',
889
+ justifyContent: 'flex-start',
890
+ alignItems: 'stretch',
891
+ flexWrap: 'nowrap',
892
+ gap: '0px',
893
+ columnGap: '0px',
894
+ rowGap: '0px',
895
+ gridTemplateColumns: 'none',
896
+ gridTemplateRows: 'none',
897
+ gridRow: 'auto',
898
+ gridColumn: 'auto',
899
+ width: `${rect.width}px`,
900
+ height: `${rect.height}px`,
901
+ minWidth: '0px',
902
+ maxWidth: 'none',
903
+ minHeight: '0px',
904
+ paddingTop: '0px',
905
+ paddingRight: '0px',
906
+ paddingBottom: '0px',
907
+ paddingLeft: '0px',
908
+ marginTop: '0px',
909
+ marginRight: '0px',
910
+ marginBottom: '0px',
911
+ marginLeft: '0px',
912
+ backgroundColor: 'rgba(0, 0, 0, 0)',
913
+ backgroundImage: 'none',
914
+ backgroundSize: 'auto',
915
+ backgroundPosition: '0% 0%',
916
+ color: 'rgba(0, 0, 0, 0)',
917
+ opacity: '1',
918
+ borderRadius: '0px',
919
+ borderTopLeftRadius: '0px',
920
+ borderTopRightRadius: '0px',
921
+ borderBottomRightRadius: '0px',
922
+ borderBottomLeftRadius: '0px',
923
+ border: '0px none rgba(0, 0, 0, 0)',
924
+ borderWidth: '0px',
925
+ borderColor: 'rgba(0, 0, 0, 0)',
926
+ borderStyle: 'none',
927
+ boxShadow: 'none',
928
+ overflow: 'visible',
929
+ overflowX: 'visible',
930
+ overflowY: 'visible',
931
+ mixBlendMode: 'normal',
932
+ transform: 'none',
933
+ fontFamily: 'Inter',
934
+ fontSize: '16px',
935
+ fontWeight: '400',
936
+ fontStyle: 'normal',
937
+ lineHeight: 'normal',
938
+ letterSpacing: 'normal',
939
+ textAlign: 'left',
940
+ textTransform: 'none',
941
+ whiteSpace: 'normal',
942
+ textDecoration: 'none',
943
+ webkitTextStrokeWidth: '0px',
944
+ webkitTextStrokeColor: 'rgba(0, 0, 0, 0)',
945
+ top: 'auto',
946
+ right: 'auto',
947
+ bottom: 'auto',
948
+ left: 'auto',
949
+ inset: 'auto',
950
+ content: 'none',
951
+ },
952
+ pseudo: {
953
+ before: null,
954
+ after: null,
955
+ },
956
+ children,
957
+ effectiveZ: maxZ,
958
+ };
959
+ }
960
+
961
+ function unionRects(rects) {
962
+ if (!Array.isArray(rects) || rects.length === 0) {
963
+ return { x: 0, y: 0, width: 0, height: 0 };
964
+ }
965
+
966
+ const left = Math.min(...rects.map((rect) => rect.x));
967
+ const top = Math.min(...rects.map((rect) => rect.y));
968
+ const right = Math.max(...rects.map((rect) => rect.x + rect.width));
969
+ const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
970
+ return {
971
+ x: left,
972
+ y: top,
973
+ width: Math.max(right - left, 0),
974
+ height: Math.max(bottom - top, 0),
975
+ };
976
+ }
977
+
978
+ function getRenderableGridStrategy(node, gridStrategy) {
979
+ if (!node || !gridStrategy?.outerFrame || !Array.isArray(node.children) || node.children.length < 2) {
980
+ return null;
981
+ }
982
+
983
+ const axis = detectLinearChildAxis(node.children);
984
+ if (!axis) {
985
+ return null;
986
+ }
987
+
988
+ return {
989
+ ...gridStrategy.outerFrame,
990
+ layoutMode: axis,
991
+ itemSpacing: measureAxisSpacing(node.children, axis),
992
+ };
993
+ }
994
+
995
+ function getRenderableInlineLayout(node) {
996
+ if (!node?.computed || node.computed.display !== 'inline-block') {
997
+ return null;
998
+ }
999
+
1000
+ const children = Array.isArray(node.children) ? node.children.filter(Boolean) : [];
1001
+ if (children.length === 0) {
1002
+ return null;
1003
+ }
1004
+
1005
+ if (children.some((child) => !child?.rect || isAbsoluteLikeNode(child))) {
1006
+ return null;
1007
+ }
1008
+
1009
+ const detectedAxis = detectLinearChildAxis(children);
1010
+ if (detectedAxis === 'VERTICAL') {
1011
+ return null;
1012
+ }
1013
+
1014
+ return {
1015
+ layoutMode: 'HORIZONTAL',
1016
+ primaryAxisAlignItems: 'MIN',
1017
+ counterAxisAlignItems: 'MIN',
1018
+ itemSpacing: measureAxisSpacing(children, 'HORIZONTAL'),
1019
+ };
1020
+ }
1021
+
1022
+ function getRenderableFlexLayout(node) {
1023
+ if (!node?.computed) {
1024
+ return null;
1025
+ }
1026
+
1027
+ const children = getPresentChildren(node);
1028
+ const layout = mapFlexLayout(node.computed);
1029
+ if (children.length === 0) {
1030
+ return { layout: withFlexSizing(node, [], layout) };
1031
+ }
1032
+
1033
+ const flowChildren = getFlowChildren(node);
1034
+ if (shouldStartAlignSingleTextFlexRow(node, flowChildren, layout)) {
1035
+ layout.primaryAxisAlignItems = 'MIN';
1036
+ }
1037
+
1038
+ if (flowChildren.length === 0) {
1039
+ return { layout: withFlexSizing(node, flowChildren, layout) };
1040
+ }
1041
+
1042
+ if (flowChildren.some((child) => !child?.rect)) {
1043
+ return { layout: withFlexSizing(node, flowChildren, layout) };
1044
+ }
1045
+
1046
+ const axis = isRowFlexDirection(node.computed.flexDirection) ? 'HORIZONTAL' : 'VERTICAL';
1047
+ const measuredSpacing = measureAxisSpacing(flowChildren, axis);
1048
+ const cssSpacing = layout.itemSpacing || 0;
1049
+ if (layout.primaryAxisAlignItems !== 'SPACE_BETWEEN' && measuredSpacing > cssSpacing) {
1050
+ layout.itemSpacing = measuredSpacing;
1051
+ }
1052
+
1053
+ return {
1054
+ layout: withFlexSizing(node, flowChildren, layout),
1055
+ };
1056
+ }
1057
+
1058
+ function withFlexSizing(node, flowChildren, layout) {
1059
+ const axis = isRowFlexDirection(node.computed.flexDirection) ? 'HORIZONTAL' : 'VERTICAL';
1060
+ const result = { ...layout };
1061
+ const primaryFreeSpace = measureFlexFreeSpace(node, flowChildren, axis);
1062
+ const counterFreeSpace = measureFlexFreeSpace(node, flowChildren, axis === 'HORIZONTAL' ? 'VERTICAL' : 'HORIZONTAL');
1063
+ const primaryAlign = String(result.primaryAxisAlignItems || 'MIN').toUpperCase();
1064
+ const counterAlign = String(result.counterAxisAlignItems || 'MIN').toUpperCase();
1065
+
1066
+ if (primaryFreeSpace > 2 || primaryAlign === 'CENTER' || primaryAlign === 'MAX' || primaryAlign === 'SPACE_BETWEEN') {
1067
+ result.primaryAxisSizingMode = 'FIXED';
1068
+ }
1069
+
1070
+ if (counterFreeSpace > 2 || counterAlign === 'CENTER' || counterAlign === 'MAX' || counterAlign === 'STRETCH') {
1071
+ result.counterAxisSizingMode = 'FIXED';
1072
+ }
1073
+
1074
+ return result;
1075
+ }
1076
+
1077
+ function measureFlexFreeSpace(node, children, axis) {
1078
+ const rect = node?.rect;
1079
+ if (!rect) {
1080
+ return 0;
1081
+ }
1082
+
1083
+ const computed = node.computed || {};
1084
+ const renderedSize = axis === 'HORIZONTAL' ? rect.width : rect.height;
1085
+ const startPadding = axis === 'HORIZONTAL' ? parsePx(computed.paddingLeft) : parsePx(computed.paddingTop);
1086
+ const endPadding = axis === 'HORIZONTAL' ? parsePx(computed.paddingRight) : parsePx(computed.paddingBottom);
1087
+ const items = (children || []).filter((child) => child?.rect);
1088
+
1089
+ if (items.length === 0) {
1090
+ return Math.max(renderedSize - startPadding - endPadding, 0);
1091
+ }
1092
+
1093
+ if (axis === 'HORIZONTAL') {
1094
+ const left = Math.min(...items.map((child) => child.rect.x));
1095
+ const right = Math.max(...items.map((child) => child.rect.x + child.rect.width));
1096
+ return Math.max(renderedSize - startPadding - endPadding - (right - left), 0);
1097
+ }
1098
+
1099
+ const top = Math.min(...items.map((child) => child.rect.y));
1100
+ const bottom = Math.max(...items.map((child) => child.rect.y + child.rect.height));
1101
+ return Math.max(renderedSize - startPadding - endPadding - (bottom - top), 0);
1102
+ }
1103
+
1104
+ function isRowFlexDirection(flexDirection) {
1105
+ return flexDirection !== 'column' && flexDirection !== 'column-reverse';
1106
+ }
1107
+
1108
+ function isAbsoluteLikeNode(node) {
1109
+ const position = node?.computed?.position;
1110
+ return position === 'absolute' || position === 'fixed';
1111
+ }
1112
+
1113
+ function getPresentChildren(node) {
1114
+ return Array.isArray(node?.children) ? node.children.filter(Boolean) : [];
1115
+ }
1116
+
1117
+ function getFlowChildren(node) {
1118
+ return getPresentChildren(node).filter((child) => !isAbsoluteLikeNode(child));
1119
+ }
1120
+
1121
+ function shouldStartAlignSingleTextFlexRow(node, flowChildren, layout) {
1122
+ const axis = isRowFlexDirection(node?.computed?.flexDirection) ? 'HORIZONTAL' : 'VERTICAL';
1123
+ if (axis !== 'HORIZONTAL' || flowChildren.length !== 1 || !isTextLikeNode(flowChildren[0])) {
1124
+ return false;
1125
+ }
1126
+
1127
+ if (!singleTextChildUsesPrimaryStretch(node, flowChildren[0])) {
1128
+ return false;
1129
+ }
1130
+
1131
+ if (hasVisibleFrameSurface(node?.computed)) {
1132
+ return false;
1133
+ }
1134
+
1135
+ const primaryAlign = String(layout?.primaryAxisAlignItems || 'MIN').toUpperCase();
1136
+ return primaryAlign === 'CENTER' || primaryAlign === 'MAX' || primaryAlign === 'SPACE_BETWEEN';
1137
+ }
1138
+
1139
+ function singleTextChildUsesPrimaryStretch(parentNode, childNode) {
1140
+ const flexGrow = parseFloat(childNode?.computed?.flexGrow);
1141
+ if (Number.isFinite(flexGrow) && flexGrow > 0) {
1142
+ return true;
1143
+ }
1144
+
1145
+ const parentRect = parentNode?.rect;
1146
+ const childRect = childNode?.rect;
1147
+ if (!parentRect || !childRect) {
1148
+ return false;
1149
+ }
1150
+
1151
+ const computed = parentNode.computed || {};
1152
+ const parentInnerWidth = Math.max(parentRect.width - parsePx(computed.paddingLeft) - parsePx(computed.paddingRight), 0);
1153
+ return fillsAxis(childRect.width, parentInnerWidth);
1154
+ }
1155
+
1156
+ function shouldHugSingleTextFlexChild(parentNode, childNode, axis) {
1157
+ if (axis !== 'HORIZONTAL' || !parentNode || !childNode) {
1158
+ return false;
1159
+ }
1160
+
1161
+ const flowChildren = getFlowChildren(parentNode);
1162
+ if (flowChildren.length !== 1 || flowChildren[0] !== childNode || !isTextLikeNode(childNode)) {
1163
+ return false;
1164
+ }
1165
+
1166
+ return shouldStartAlignSingleTextFlexRow(parentNode, flowChildren, mapFlexLayout(parentNode.computed || {}));
1167
+ }
1168
+
1169
+ function isTextLikeNode(node) {
1170
+ return Boolean(node?.text && node?.isTextContainer);
1171
+ }
1172
+
1173
+ function hasVisibleFrameSurface(computed = {}) {
1174
+ if (!isTransparentCssColor(computed.backgroundColor)) {
1175
+ return true;
1176
+ }
1177
+
1178
+ const backgroundImage = String(computed.backgroundImage || 'none').trim().toLowerCase();
1179
+ if (backgroundImage && backgroundImage !== 'none') {
1180
+ return true;
1181
+ }
1182
+
1183
+ const boxShadow = String(computed.boxShadow || 'none').trim().toLowerCase();
1184
+ if (boxShadow && boxShadow !== 'none') {
1185
+ return true;
1186
+ }
1187
+
1188
+ return hasVisibleBorder(computed);
1189
+ }
1190
+
1191
+ function hasVisibleBorder(computed = {}) {
1192
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
1193
+ return sides.some((side) => {
1194
+ const width = parsePx(computed[`border${side}Width`] ?? computed.borderWidth);
1195
+ const style = String(computed[`border${side}Style`] ?? computed.borderStyle ?? 'none').toLowerCase();
1196
+ const color = computed[`border${side}Color`] ?? computed.borderColor ?? computed.color;
1197
+ return width > 0 && style !== 'none' && style !== 'hidden' && !isTransparentCssColor(color);
1198
+ });
1199
+ }
1200
+
1201
+ function hasSignificantFlexChildMargins(children, axis) {
1202
+ return children.some((child) => {
1203
+ const computed = child?.computed || {};
1204
+ if (axis === 'HORIZONTAL') {
1205
+ return Math.abs(parsePx(computed.marginLeft)) > 0.5 || Math.abs(parsePx(computed.marginRight)) > 0.5;
1206
+ }
1207
+
1208
+ return Math.abs(parsePx(computed.marginTop)) > 0.5 || Math.abs(parsePx(computed.marginBottom)) > 0.5;
1209
+ });
1210
+ }
1211
+
1212
+ function hasUnevenFlexChildGaps(children, axis) {
1213
+ const gaps = measureAxisGaps(children, axis);
1214
+ if (gaps.length <= 1) {
1215
+ return false;
1216
+ }
1217
+
1218
+ const minGap = Math.min(...gaps);
1219
+ const maxGap = Math.max(...gaps);
1220
+ const tolerance = Math.max(8, Math.round(Math.abs(minGap) * 0.25));
1221
+ return maxGap - minGap > tolerance;
1222
+ }
1223
+
1224
+ function isFlexDisplay(display) {
1225
+ return display === 'flex' || display === 'inline-flex';
1226
+ }
1227
+
1228
+ function detectLinearChildAxis(children) {
1229
+ const tolerance = 8;
1230
+ const xs = groupAxisValues(children.map((child) => child.rect?.x ?? 0), tolerance);
1231
+ const ys = groupAxisValues(children.map((child) => child.rect?.y ?? 0), tolerance);
1232
+
1233
+ if (ys.length === 1 && xs.length > 1) {
1234
+ return 'HORIZONTAL';
1235
+ }
1236
+ if (xs.length === 1 && ys.length > 1) {
1237
+ return 'VERTICAL';
1238
+ }
1239
+ return null;
1240
+ }
1241
+
1242
+ function groupAxisValues(values, tolerance) {
1243
+ const sorted = [...values].sort((a, b) => a - b);
1244
+ const groups = [];
1245
+
1246
+ for (const value of sorted) {
1247
+ const prev = groups[groups.length - 1];
1248
+ if (prev === undefined || Math.abs(value - prev) > tolerance) {
1249
+ groups.push(value);
1250
+ }
1251
+ }
1252
+
1253
+ return groups;
1254
+ }
1255
+
1256
+ function measureAxisGaps(children, axis) {
1257
+ const items = [...children]
1258
+ .filter((child) => child?.rect)
1259
+ .sort((a, b) => axis === 'HORIZONTAL' ? a.rect.x - b.rect.x : a.rect.y - b.rect.y);
1260
+
1261
+ const gaps = [];
1262
+ for (let index = 1; index < items.length; index++) {
1263
+ const prev = items[index - 1].rect;
1264
+ const current = items[index].rect;
1265
+ const gap = axis === 'HORIZONTAL'
1266
+ ? current.x - (prev.x + prev.width)
1267
+ : current.y - (prev.y + prev.height);
1268
+ if (gap >= 0) {
1269
+ gaps.push(gap);
1270
+ }
1271
+ }
1272
+
1273
+ return gaps;
1274
+ }
1275
+
1276
+ function measureAxisSpacing(children, axis) {
1277
+ const gaps = measureAxisGaps(children, axis);
1278
+ let minGap = null;
1279
+ for (let index = 0; index < gaps.length; index++) {
1280
+ if (minGap === null || gaps[index] < minGap) {
1281
+ minGap = gaps[index];
1282
+ }
1283
+ }
1284
+
1285
+ return Math.max(Math.round(minGap ?? 0), 0);
1286
+ }
1287
+
1288
+ function findBestPseudoParent(node, pseudo) {
1289
+ let best = null;
1290
+
1291
+ function walk(current, depth = 0) {
1292
+ if (!current || !current.rect || current.isTextContainer) return;
1293
+
1294
+ const score = scorePseudoParent(current, pseudo, depth);
1295
+ if (score > 0 && (!best || score > best.score)) {
1296
+ best = { node: current, score };
1297
+ }
1298
+
1299
+ for (const child of current.children || []) {
1300
+ walk(child, depth + 1);
1301
+ }
1302
+ }
1303
+
1304
+ walk(node, 0);
1305
+ return best?.node ?? null;
1306
+ }
1307
+
1308
+ function scorePseudoParent(node, pseudo, depth) {
1309
+ const rect = node.rect;
1310
+ if (!rect) return 0;
1311
+
1312
+ const nodeArea = Math.max(rect.width * rect.height, 1);
1313
+ const pseudoArea = Math.max((pseudo.width || 0) * (pseudo.height || 0), 1);
1314
+ const contains =
1315
+ pseudo.x >= rect.x - 8 &&
1316
+ pseudo.y >= rect.y - 8 &&
1317
+ pseudo.x + pseudo.width <= rect.x + rect.width + 8 &&
1318
+ pseudo.y + pseudo.height <= rect.y + rect.height + 8;
1319
+ const intersects =
1320
+ pseudo.x < rect.x + rect.width &&
1321
+ pseudo.x + pseudo.width > rect.x &&
1322
+ pseudo.y < rect.y + rect.height &&
1323
+ pseudo.y + pseudo.height > rect.y;
1324
+
1325
+ if (!contains && !intersects) {
1326
+ return 0;
1327
+ }
1328
+
1329
+ const haystack = `${node.tag ?? ''} ${(node.classList || []).join(' ')} ${node.name ?? ''}`.toLowerCase();
1330
+ const tokens = String(pseudo.name || '')
1331
+ .toLowerCase()
1332
+ .split(/[^a-z0-9]+/g)
1333
+ .filter((token) => token.length > 2);
1334
+ let tokenHits = 0;
1335
+ for (const token of tokens) {
1336
+ if (haystack.includes(token)) tokenHits++;
1337
+ }
1338
+
1339
+ if (tokenHits === 0 && depth > 0) {
1340
+ const nearSizedContainer = nodeArea <= pseudoArea * 64;
1341
+ if (!nearSizedContainer) {
1342
+ return 0;
1343
+ }
1344
+ }
1345
+
1346
+ let score = tokenHits * 1000;
1347
+ if (contains) score += 500;
1348
+ else if (intersects) score += 120;
1349
+ score += Math.min(400, Math.round(100000 / nodeArea));
1350
+ score += Math.min(100, depth * 5);
1351
+ score += Math.min(80, Math.round(100000 / pseudoArea));
1352
+ return score;
1353
+ }
1354
+
1355
+ function buildTextRuns(runs, fontMap) {
1356
+ return (runs || [])
1357
+ .filter((run) => run && run.text)
1358
+ .map((run) => ({
1359
+ text: run.text,
1360
+ lineIndex: run.lineIndex || 0,
1361
+ ...mapTypography(run.computed, fontMap),
1362
+ ...mapTextStroke(run.computed),
1363
+ }));
1364
+ }
1365
+
1366
+ function mapFlexTextAlignment(computed) {
1367
+ if (!computed || (computed.display !== 'flex' && computed.display !== 'inline-flex')) {
1368
+ return {};
1369
+ }
1370
+
1371
+ const isRow = computed.flexDirection !== 'column' && computed.flexDirection !== 'column-reverse';
1372
+ const primary = mapFlexTextAxisAlignment(computed.justifyContent, 'primary');
1373
+ const counter = mapFlexTextAxisAlignment(computed.alignItems, 'counter');
1374
+ const result = {};
1375
+
1376
+ if (isRow) {
1377
+ if (primary.horizontal) result.textAlignHorizontal = primary.horizontal;
1378
+ if (counter.vertical) result.textAlignVertical = counter.vertical;
1379
+ } else {
1380
+ if (counter.horizontal) result.textAlignHorizontal = counter.horizontal;
1381
+ if (primary.vertical) result.textAlignVertical = primary.vertical;
1382
+ }
1383
+
1384
+ return result;
1385
+ }
1386
+
1387
+ function mapFlexTextAxisAlignment(value, axisRole) {
1388
+ const normalized = String(value || '').toLowerCase();
1389
+ const horizontalMap = {
1390
+ center: 'CENTER',
1391
+ 'flex-start': 'LEFT',
1392
+ start: 'LEFT',
1393
+ left: 'LEFT',
1394
+ 'flex-end': 'RIGHT',
1395
+ end: 'RIGHT',
1396
+ right: 'RIGHT',
1397
+ };
1398
+ const verticalMap = {
1399
+ center: 'CENTER',
1400
+ 'flex-start': 'TOP',
1401
+ start: 'TOP',
1402
+ 'flex-end': 'BOTTOM',
1403
+ end: 'BOTTOM',
1404
+ };
1405
+
1406
+ return {
1407
+ horizontal: horizontalMap[normalized] || null,
1408
+ vertical: verticalMap[normalized] || null,
1409
+ axisRole,
1410
+ };
1411
+ }
1412
+
1413
+ function detectBackgroundPattern(computed) {
1414
+ const backgroundImage = computed.backgroundImage || '';
1415
+ const backgroundSize = computed.backgroundSize || '';
1416
+ if (!backgroundImage.includes('linear-gradient') || !backgroundSize.includes('px')) {
1417
+ return null;
1418
+ }
1419
+
1420
+ const gradientCount = (backgroundImage.match(/linear-gradient\(/g) || []).length;
1421
+ if (gradientCount < 2) {
1422
+ return null;
1423
+ }
1424
+
1425
+ const sizeMatch = backgroundSize.match(/([\d.]+)px\s+([\d.]+)px/);
1426
+ const colorMatch = backgroundImage.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/);
1427
+ if (!sizeMatch || !colorMatch) {
1428
+ return null;
1429
+ }
1430
+
1431
+ return {
1432
+ kind: 'grid',
1433
+ cellWidth: Math.max(Math.round(parseFloat(sizeMatch[1])), 1),
1434
+ cellHeight: Math.max(Math.round(parseFloat(sizeMatch[2])), 1),
1435
+ strokeWeight: 1,
1436
+ paint: colorSolidPaint(colorMatch[0]),
1437
+ };
1438
+ }
src/pipeline/convert.js CHANGED
@@ -1,68 +1,74 @@
1
- /**
2
- * Shared tempelhtml conversion pipeline.
3
- * Reused by the CLI and the local HTTP bridge for the Figma plugin.
4
- */
5
-
6
- import { extractFromFile, extractFromHtml } from '../core/extractor.js';
7
- import { resolveFonts } from '../figma/font-resolver.js';
8
- import { sortByZIndex } from '../figma/z-index-sorter.js';
9
- import { buildFigmaTree } from '../figma/mapper.js';
10
-
11
- const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
12
-
13
- /**
14
- * @param {string} inputPath
15
- * @param {ConvertOptions} options
16
- */
17
- export async function convertHtmlFile(inputPath, options = {}) {
18
- const viewport = normalizeViewport(options.viewport);
19
- return convertWithExtractor({
20
- extractor: () => extractFromFile(inputPath, viewport),
21
- source: inputPath,
22
- viewport,
23
- onProgress: options.onProgress,
24
- });
25
- }
26
-
27
- /**
28
- * @param {string} html
29
- * @param {ConvertHtmlOptions} options
30
- */
31
- export async function convertHtmlString(html, options = {}) {
32
- const viewport = normalizeViewport(options.viewport);
33
- return convertWithExtractor({
34
- extractor: () => extractFromHtml(html, {
35
- ...viewport,
36
- baseUrl: options.baseUrl ?? null,
37
- }),
38
- source: options.sourceName ?? 'inline.html',
39
- viewport,
40
- baseUrl: options.baseUrl ?? null,
41
- onProgress: options.onProgress,
42
- });
43
- }
44
-
45
- async function convertWithExtractor({ extractor, source, viewport, baseUrl = null, onProgress = null }) {
46
- progress(onProgress, 5, 'Extracting page...');
47
- const { domTree } = await extractor();
48
-
49
- progress(onProgress, 78, 'Resolving fonts...');
50
- const fontMap = await resolveFonts(domTree);
51
  progress(onProgress, 86, 'Building Figma tree...');
52
  const sorted = sortByZIndex(domTree);
53
  const figmaTree = buildFigmaTree(sorted, { fontMap });
 
54
  progress(onProgress, 90, 'Snapshot ready. Sending to Figma...');
55
 
56
  return {
57
  version: '0.1.0',
58
  meta: {
59
  source,
 
60
  viewport,
61
  ...(baseUrl ? { baseUrl } : {}),
62
  },
63
- warnings: [],
64
- figmaTree,
65
- };
 
 
 
 
66
  }
67
 
68
  function progress(onProgress, percent, message) {
@@ -70,18 +76,18 @@ function progress(onProgress, percent, message) {
70
  onProgress(percent, message);
71
  }
72
  }
73
-
74
- function normalizeViewport(viewport = {}) {
75
- const width = Number.parseInt(viewport.width ?? DEFAULT_VIEWPORT.width, 10);
76
- const height = Number.parseInt(viewport.height ?? DEFAULT_VIEWPORT.height, 10);
77
-
78
- return {
79
- width: Number.isFinite(width) ? width : DEFAULT_VIEWPORT.width,
80
- height: Number.isFinite(height) ? height : DEFAULT_VIEWPORT.height,
81
- };
82
- }
83
-
84
- /**
85
- * @typedef {{ viewport?: { width?: number, height?: number } }} ConvertOptions
86
- * @typedef {ConvertOptions & { sourceName?: string, baseUrl?: string | null }} ConvertHtmlOptions
87
- */
 
1
+ /**
2
+ * Shared Morphus conversion pipeline.
3
+ * Reused by the CLI and the local HTTP bridge for the Figma plugin.
4
+ */
5
+
6
+ import { extractFromFile, extractFromHtml } from '../core/extractor.js';
7
+ import { resolveFonts } from '../figma/font-resolver.js';
8
+ import { sortByZIndex } from '../figma/z-index-sorter.js';
9
+ import { buildFigmaTree } from '../figma/mapper.js';
10
+
11
+ const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
12
+
13
+ /**
14
+ * @param {string} inputPath
15
+ * @param {ConvertOptions} options
16
+ */
17
+ export async function convertHtmlFile(inputPath, options = {}) {
18
+ const viewport = normalizeViewport(options.viewport);
19
+ return convertWithExtractor({
20
+ extractor: () => extractFromFile(inputPath, viewport),
21
+ source: inputPath,
22
+ viewport,
23
+ onProgress: options.onProgress,
24
+ });
25
+ }
26
+
27
+ /**
28
+ * @param {string} html
29
+ * @param {ConvertHtmlOptions} options
30
+ */
31
+ export async function convertHtmlString(html, options = {}) {
32
+ const viewport = normalizeViewport(options.viewport);
33
+ return convertWithExtractor({
34
+ extractor: () => extractFromHtml(html, {
35
+ ...viewport,
36
+ baseUrl: options.baseUrl ?? null,
37
+ }),
38
+ source: options.sourceName ?? 'inline.html',
39
+ viewport,
40
+ baseUrl: options.baseUrl ?? null,
41
+ onProgress: options.onProgress,
42
+ });
43
+ }
44
+
45
+ async function convertWithExtractor({ extractor, source, viewport, baseUrl = null, onProgress = null }) {
46
+ progress(onProgress, 5, 'Extracting page...');
47
+ const { domTree, title } = await extractor();
48
+
49
+ progress(onProgress, 78, 'Resolving fonts...');
50
+ const fontMap = await resolveFonts(domTree);
51
  progress(onProgress, 86, 'Building Figma tree...');
52
  const sorted = sortByZIndex(domTree);
53
  const figmaTree = buildFigmaTree(sorted, { fontMap });
54
+ const documentTitle = normalizeDocumentTitle(title);
55
  progress(onProgress, 90, 'Snapshot ready. Sending to Figma...');
56
 
57
  return {
58
  version: '0.1.0',
59
  meta: {
60
  source,
61
+ ...(documentTitle ? { title: documentTitle } : {}),
62
  viewport,
63
  ...(baseUrl ? { baseUrl } : {}),
64
  },
65
+ warnings: [],
66
+ figmaTree,
67
+ };
68
+ }
69
+
70
+ function normalizeDocumentTitle(title) {
71
+ return String(title || '').replace(/\s+/g, ' ').trim();
72
  }
73
 
74
  function progress(onProgress, percent, message) {
 
76
  onProgress(percent, message);
77
  }
78
  }
79
+
80
+ function normalizeViewport(viewport = {}) {
81
+ const width = Number.parseInt(viewport.width ?? DEFAULT_VIEWPORT.width, 10);
82
+ const height = Number.parseInt(viewport.height ?? DEFAULT_VIEWPORT.height, 10);
83
+
84
+ return {
85
+ width: Number.isFinite(width) ? width : DEFAULT_VIEWPORT.width,
86
+ height: Number.isFinite(height) ? height : DEFAULT_VIEWPORT.height,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * @typedef {{ viewport?: { width?: number, height?: number } }} ConvertOptions
92
+ * @typedef {ConvertOptions & { sourceName?: string, baseUrl?: string | null }} ConvertHtmlOptions
93
+ */
src/utils/color.js CHANGED
@@ -1,68 +1,68 @@
1
- /**
2
- * src/utils/color.js
3
- * Color conversion utilities for CSS → Figma RGB (0-1 range).
4
- */
5
-
6
- /**
7
- * Convert hex color to Figma RGB object.
8
- * @param {string} hex - e.g. "#c9a84c" or "#fff"
9
- * @returns {{ r: number, g: number, b: number }}
10
- */
11
- export function hexToFigmaRGB(hex) {
12
- const clean = hex.replace('#', '');
13
- const full = clean.length === 3
14
- ? clean.split('').map(c => c + c).join('')
15
- : clean;
16
- const n = parseInt(full, 16);
17
- return {
18
- r: ((n >> 16) & 255) / 255,
19
- g: ((n >> 8) & 255) / 255,
20
- b: (n & 255) / 255,
21
- };
22
- }
23
-
24
- /**
25
- * Convert CSS rgba() string to Figma RGBA.
26
- * @param {string} rgba - e.g. "rgba(201, 168, 76, 0.3)"
27
- * @returns {{ r: number, g: number, b: number, a: number }}
28
- */
29
- export function rgbaStringToFigma(rgba) {
30
- const m = rgba.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/);
31
- if (!m) return { r: 0, g: 0, b: 0, a: 1 };
32
- return {
33
- r: parseFloat(m[1]) / 255,
34
- g: parseFloat(m[2]) / 255,
35
- b: parseFloat(m[3]) / 255,
36
- a: m[4] !== undefined ? parseFloat(m[4]) : 1,
37
- };
38
- }
39
-
40
- /**
41
- * Parse any CSS color string → Figma RGBA.
42
- * Handles: hex, rgba(), rgb()
43
- */
44
- export function cssColorToFigma(color) {
45
- if (!color || color === 'transparent' || color === 'none') {
46
- return { r: 0, g: 0, b: 0, a: 0 };
47
- }
48
- if (color.startsWith('#')) {
49
- return { ...hexToFigmaRGB(color), a: 1 };
50
- }
51
- if (color.startsWith('rgb')) {
52
- return rgbaStringToFigma(color);
53
- }
54
- // Fallback
55
- return { r: 0, g: 0, b: 0, a: 1 };
56
- }
57
-
58
- /**
59
- * Build a Figma solid paint object.
60
- */
61
- export function solidPaint(cssColor, opacity = 1) {
62
- const { r, g, b, a } = cssColorToFigma(cssColor);
63
- return {
64
- type: 'SOLID',
65
- color: { r, g, b },
66
- opacity: opacity * a,
67
- };
68
- }
 
1
+ /**
2
+ * src/utils/color.js
3
+ * Color conversion utilities for CSS → Figma RGB (0-1 range).
4
+ */
5
+
6
+ /**
7
+ * Convert hex color to Figma RGB object.
8
+ * @param {string} hex - e.g. "#c9a84c" or "#fff"
9
+ * @returns {{ r: number, g: number, b: number }}
10
+ */
11
+ export function hexToFigmaRGB(hex) {
12
+ const clean = hex.replace('#', '');
13
+ const full = clean.length === 3
14
+ ? clean.split('').map(c => c + c).join('')
15
+ : clean;
16
+ const n = parseInt(full, 16);
17
+ return {
18
+ r: ((n >> 16) & 255) / 255,
19
+ g: ((n >> 8) & 255) / 255,
20
+ b: (n & 255) / 255,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Convert CSS rgba() string to Figma RGBA.
26
+ * @param {string} rgba - e.g. "rgba(201, 168, 76, 0.3)"
27
+ * @returns {{ r: number, g: number, b: number, a: number }}
28
+ */
29
+ export function rgbaStringToFigma(rgba) {
30
+ const m = rgba.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/);
31
+ if (!m) return { r: 0, g: 0, b: 0, a: 1 };
32
+ return {
33
+ r: parseFloat(m[1]) / 255,
34
+ g: parseFloat(m[2]) / 255,
35
+ b: parseFloat(m[3]) / 255,
36
+ a: m[4] !== undefined ? parseFloat(m[4]) : 1,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Parse any CSS color string → Figma RGBA.
42
+ * Handles: hex, rgba(), rgb()
43
+ */
44
+ export function cssColorToFigma(color) {
45
+ if (!color || color === 'transparent' || color === 'none') {
46
+ return { r: 0, g: 0, b: 0, a: 0 };
47
+ }
48
+ if (color.startsWith('#')) {
49
+ return { ...hexToFigmaRGB(color), a: 1 };
50
+ }
51
+ if (color.startsWith('rgb')) {
52
+ return rgbaStringToFigma(color);
53
+ }
54
+ // Fallback
55
+ return { r: 0, g: 0, b: 0, a: 1 };
56
+ }
57
+
58
+ /**
59
+ * Build a Figma solid paint object.
60
+ */
61
+ export function solidPaint(cssColor, opacity = 1) {
62
+ const { r, g, b, a } = cssColorToFigma(cssColor);
63
+ return {
64
+ type: 'SOLID',
65
+ color: { r, g, b },
66
+ opacity: opacity * a,
67
+ };
68
+ }