Spaces:
Sleeping
Sleeping
Upload 15 files
Browse files- package-lock.json +2 -2
- package.json +2 -2
- scripts/convert.js +1 -1
- scripts/server.js +3 -3
- src/core/extractor.js +1304 -964
- src/figma/css-to-figma.js +465 -419
- src/figma/font-resolver.js +60 -33
- src/figma/mapper.js +1326 -941
- src/pipeline/convert.js +74 -68
- src/utils/color.js +68 -68
package-lock.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"version": "0.1.0",
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
-
"name": "
|
| 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": "
|
| 3 |
"version": "0.1.0",
|
| 4 |
-
"description": "HTML
|
| 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 |
-
*
|
| 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
|
| 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.
|
| 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(`
|
| 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
|
| 49 |
-
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
const cs = window.getComputedStyle(el);
|
| 68 |
-
if (cs.opacity === '0') {
|
| 69 |
el.style.opacity = '1';
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 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, '&')
|
| 116 |
-
.replace(/"/g, '"');
|
| 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 |
-
|
| 154 |
-
if (rect.width === 0 && rect.height === 0 && cs.position === 'static') return null;
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
&&
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 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 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 361 |
-
if (
|
| 362 |
-
|
| 363 |
-
return;
|
| 364 |
}
|
| 365 |
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
const tagName = element.tagName.toLowerCase();
|
| 370 |
-
|
| 371 |
-
if (tagName === 'br') {
|
| 372 |
-
pieces.push('\n');
|
| 373 |
-
lineIndex++;
|
| 374 |
-
return;
|
| 375 |
}
|
| 376 |
|
| 377 |
-
const
|
| 378 |
-
|
| 379 |
-
|
| 380 |
}
|
| 381 |
-
}
|
| 382 |
|
| 383 |
-
|
| 384 |
-
|
| 385 |
}
|
| 386 |
|
| 387 |
-
|
| 388 |
-
.
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 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 |
-
|
| 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 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 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 |
-
|
| 465 |
-
if (!isUsableSvgPresentationValue(value)) {
|
| 466 |
-
return;
|
| 467 |
}
|
| 468 |
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
function isUsableSvgPresentationValue(value) {
|
| 473 |
-
if (value === undefined || value === null) {
|
| 474 |
-
return false;
|
| 475 |
}
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 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 |
-
|
| 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 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 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 |
-
|
| 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
|
| 678 |
-
|
| 679 |
-
|
| 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 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 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 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 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
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
const
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
const
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
.
|
| 857 |
-
.
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
const
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
return
|
| 964 |
-
}
|
| 965 |
-
|
| 966 |
-
function
|
| 967 |
-
if (!
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
const
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
}
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
}
|
| 1011 |
-
|
| 1012 |
-
|
| 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, '&')
|
| 371 |
+
.replace(/"/g, '"');
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 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 |
-
|
| 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 |
-
*
|
| 109 |
-
*
|
| 110 |
*/
|
| 111 |
-
export function
|
| 112 |
-
const
|
| 113 |
-
|
| 114 |
-
// Extract color stops (simplified — handles rgba and hex)
|
| 115 |
-
const stops = extractGradientStops(cssGradient);
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 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 |
-
|
| 164 |
-
|
| 165 |
-
const angle = parseFloat(degMatch[1]);
|
| 166 |
-
if (Number.isFinite(angle)) return angle;
|
| 167 |
}
|
| 168 |
|
| 169 |
-
return
|
| 170 |
}
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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)
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
...
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
if (base.type === 'TEXT') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
return {
|
| 72 |
...base,
|
| 73 |
characters: text,
|
| 74 |
-
...
|
| 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
|
| 87 |
-
|
| 88 |
-
const layout = isFlex
|
| 89 |
-
?
|
| 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 |
-
|
| 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 |
-
|
| 114 |
-
.
|
| 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 |
-
|
| 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, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 196 |
-
const
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
const
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
:
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
const
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
const
|
| 330 |
-
const
|
| 331 |
-
const
|
| 332 |
-
const
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
};
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 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
|
| 539 |
-
|
| 540 |
-
return tag;
|
| 541 |
}
|
| 542 |
|
| 543 |
-
function
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
.replace(/^-|-$/g, '')
|
| 548 |
-
|| 'el';
|
| 549 |
-
|
| 550 |
-
return `${tag}-${slug}-${path.replace(/\./g, '-')}`;
|
| 551 |
-
}
|
| 552 |
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
}
|
| 557 |
-
|
| 558 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 564 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
|
| 566 |
return {
|
| 567 |
id: buildStableId(tag, classList, `${path}-inner`),
|
| 568 |
name: `${buildName(tag, classList)} / ${nameSuffix}`,
|
| 569 |
type: 'TEXT',
|
| 570 |
-
x: Math.round(
|
| 571 |
-
y: Math.round(
|
| 572 |
-
width,
|
| 573 |
-
height,
|
| 574 |
characters: text,
|
| 575 |
-
...
|
| 576 |
...mapFlexTextAlignment(computed),
|
| 577 |
...mapTextStroke(computed),
|
| 578 |
textRuns: buildTextRuns(textRuns, ctx.fontMap),
|
| 579 |
};
|
| 580 |
}
|
| 581 |
|
| 582 |
-
function
|
| 583 |
-
if (!
|
| 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 |
-
|
| 630 |
-
|
| 631 |
-
const shortEnough = (node.rect.height ?? 0) <= Math.max((rootRect?.height ?? 0) * 0.2, 220);
|
| 632 |
-
return nearTop && wideEnough && shortEnough;
|
| 633 |
}
|
| 634 |
|
| 635 |
-
function
|
| 636 |
-
const
|
| 637 |
-
|
| 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
|
| 787 |
-
if (
|
| 788 |
-
return
|
| 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 |
-
|
| 811 |
-
|
| 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
|
| 858 |
-
|
| 859 |
-
|
| 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
|
| 901 |
}
|
| 902 |
|
| 903 |
-
function
|
| 904 |
-
|
| 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 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 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 |
-
|
| 1023 |
-
|
| 1024 |
-
|
|
|
|
| 1025 |
};
|
| 1026 |
}
|
| 1027 |
|
| 1028 |
-
function
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
if (!backgroundImage.includes('linear-gradient') || !backgroundSize.includes('px')) {
|
| 1032 |
-
return null;
|
| 1033 |
}
|
| 1034 |
|
| 1035 |
-
const
|
| 1036 |
-
|
| 1037 |
-
|
| 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 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 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
|
| 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 |
+
}
|