jehian commited on
Commit
dc7ee79
Β·
verified Β·
1 Parent(s): 5d8501b

Upload 16 files

Browse files
Dockerfile CHANGED
@@ -1,18 +1,18 @@
1
- FROM mcr.microsoft.com/playwright:v1.60.0-noble
2
-
3
- ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
4
-
5
- WORKDIR /app
6
-
7
- COPY package*.json ./
8
- RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund
9
-
10
- COPY . .
11
-
12
- ENV NODE_ENV=production
13
- ENV HOST=0.0.0.0
14
- ENV PORT=7860
15
-
16
- EXPOSE 7860
17
-
18
- CMD ["npm", "start"]
 
1
+ FROM mcr.microsoft.com/playwright:v1.60.0-noble
2
+
3
+ ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
4
+
5
+ WORKDIR /app
6
+
7
+ COPY package*.json ./
8
+ RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund
9
+
10
+ COPY . .
11
+
12
+ ENV NODE_ENV=production
13
+ ENV HOST=0.0.0.0
14
+ ENV PORT=7860
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["npm", "start"]
README.md CHANGED
@@ -1,49 +1,49 @@
1
- ---
2
- title: Morphus
3
- sdk: docker
4
- app_port: 7860
5
- pinned: false
6
- ---
7
-
8
- # Morphus
9
-
10
- Morphus converts HTML into editable Figma designs with a local HTML-first flow.
11
-
12
- ## Flow
13
- 1. Playwright renders the HTML and captures computed styles.
14
- 2. The local pipeline resolves fonts, ordering, and Figma-ready layout data.
15
- 3. A local server returns Figma-ready JSON.
16
- 4. The Figma plugin builds the design from that JSON automatically.
17
-
18
- ## Quickstart
19
- ```bash
20
- npm install
21
- npx playwright install chromium
22
- npm run server
23
- ```
24
-
25
- Then in Figma: open the Morphus plugin, paste or upload HTML, and click `Convert & Build`.
26
-
27
- ## Commands
28
- ```bash
29
- npm run convert -- --input ./tests/vela/input.html --output ./out/vela.json
30
- npm run server
31
- npm test
32
- npm run snapshot:update
33
- ```
34
-
35
- ## Project Checklist
36
- See [CHECKLIST.md](CHECKLIST.md) for the list of completed project items.
37
-
38
- ## Public Use
39
- To let other people use the plugin without running the local server, deploy the converter as a public HTTPS Node/Playwright service and update the plugin's converter URL. See [docs/deployment.md](docs/deployment.md).
40
-
41
- ## Snapshot Test
42
- `tests/vela/expected-snapshot.json` is the deterministic baseline for `tests/vela/input.html`.
43
-
44
- ## Project Layout
45
- - `scripts/convert.js` CLI conversion
46
- - `scripts/server.js` local bridge for the plugin
47
- - `figma-plugin/` Figma UI and builder
48
- - `src/` Playwright extraction and Figma mapping code
49
- - `tests/vela/` fixture and snapshot
 
1
+ ---
2
+ title: Morphus
3
+ sdk: docker
4
+ app_port: 7860
5
+ pinned: false
6
+ ---
7
+
8
+ # Morphus
9
+
10
+ Morphus converts HTML into editable Figma designs with a local HTML-first flow.
11
+
12
+ ## Flow
13
+ 1. Playwright renders the HTML and captures computed styles.
14
+ 2. The local pipeline resolves fonts, ordering, and Figma-ready layout data.
15
+ 3. A local server returns Figma-ready JSON.
16
+ 4. The Figma plugin builds the design from that JSON automatically.
17
+
18
+ ## Quickstart
19
+ ```bash
20
+ npm install
21
+ npx playwright install chromium
22
+ npm run server
23
+ ```
24
+
25
+ Then in Figma: open the Morphus plugin, paste or upload HTML, and click `Convert & Build`.
26
+
27
+ ## Commands
28
+ ```bash
29
+ npm run convert -- --input ./tests/landing-page/input.html --output ./out/landing-page.json
30
+ npm run server
31
+ npm test
32
+ npm run snapshot:update
33
+ ```
34
+
35
+ ## Project Checklist
36
+ See [CHECKLIST.md](CHECKLIST.md) for the list of completed project items.
37
+
38
+ ## Public Use
39
+ To let other people use the plugin without running the local server, deploy the converter as a public HTTPS Node/Playwright service and update the plugin's converter URL. See [docs/deployment.md](docs/deployment.md).
40
+
41
+ ## Snapshot Test
42
+ `tests/landing-page/expected-snapshot.json` is the deterministic baseline for `tests/landing-page/input.html`.
43
+
44
+ ## Project Layout
45
+ - `scripts/convert.js` CLI conversion
46
+ - `scripts/server.js` local bridge for the plugin
47
+ - `figma-plugin/` Figma UI and builder
48
+ - `src/` Playwright extraction and Figma mapping code
49
+ - `tests/landing-page/` fixture and snapshot
package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -1,22 +1,22 @@
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": {
8
- "convert": "node scripts/convert.js",
9
- "start": "node scripts/server.js",
10
- "server": "node scripts/server.js",
11
- "snapshot:update": "node scripts/update-snapshot.js",
12
- "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js"
13
- },
14
- "dependencies": {
15
- "commander": "^12.0.0",
16
- "playwright-core": "^1.60.0"
17
- },
18
- "devDependencies": {
19
- "jest": "^29.7.0",
20
- "playwright": "^1.60.0"
21
- }
22
- }
 
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": {
8
+ "convert": "node scripts/convert.js",
9
+ "start": "node scripts/server.js",
10
+ "server": "node scripts/server.js",
11
+ "snapshot:update": "node scripts/update-snapshot.js",
12
+ "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js"
13
+ },
14
+ "dependencies": {
15
+ "commander": "^12.0.0",
16
+ "playwright-core": "^1.60.0"
17
+ },
18
+ "devDependencies": {
19
+ "jest": "^29.7.0",
20
+ "playwright": "^1.60.0"
21
+ }
22
+ }
scripts/convert.js CHANGED
@@ -1,45 +1,45 @@
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
-
7
- import { program } from 'commander';
8
- import { writeFileSync, mkdirSync } from 'fs';
9
- import { dirname } from 'path';
10
- import { convertHtmlFile } from '../src/pipeline/convert.js';
11
-
12
- program
13
- .requiredOption('--input <path>', 'Path to input HTML file')
14
- .requiredOption('--output <path>', 'Path for output JSON (loaded by Figma plugin)')
15
- .option('--width <px>', 'Viewport width', '1440')
16
- .option('--height <px>', 'Viewport height', '900')
17
- .parse();
18
-
19
- const opts = program.opts();
20
-
21
- async function run() {
22
- console.log(`\nConverting ${opts.input}...`);
23
-
24
- const output = await convertHtmlFile(opts.input, {
25
- viewport: {
26
- width: parseInt(opts.width, 10),
27
- height: parseInt(opts.height, 10),
28
- },
29
- });
30
-
31
- mkdirSync(dirname(opts.output), { recursive: true });
32
- writeFileSync(opts.output, JSON.stringify(output, null, 2));
33
-
34
- for (const warning of output.warnings ?? []) {
35
- console.warn(`Warning: ${warning}`);
36
- }
37
-
38
- console.log(`\nDone. Output -> ${opts.output}`);
39
- console.log('Load this file in the Figma plugin, or use the plugin HTML flow with the local server.\n');
40
- }
41
-
42
- run().catch((err) => {
43
- console.error('Conversion failed:', err);
44
- process.exit(1);
45
- });
 
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
+
7
+ import { program } from 'commander';
8
+ import { writeFileSync, mkdirSync } from 'fs';
9
+ import { dirname } from 'path';
10
+ import { convertHtmlFile } from '../src/pipeline/convert.js';
11
+
12
+ program
13
+ .requiredOption('--input <path>', 'Path to input HTML file')
14
+ .requiredOption('--output <path>', 'Path for output JSON (loaded by Figma plugin)')
15
+ .option('--width <px>', 'Viewport width', '1440')
16
+ .option('--height <px>', 'Viewport height', '900')
17
+ .parse();
18
+
19
+ const opts = program.opts();
20
+
21
+ async function run() {
22
+ console.log(`\nConverting ${opts.input}...`);
23
+
24
+ const output = await convertHtmlFile(opts.input, {
25
+ viewport: {
26
+ width: parseInt(opts.width, 10),
27
+ height: parseInt(opts.height, 10),
28
+ },
29
+ });
30
+
31
+ mkdirSync(dirname(opts.output), { recursive: true });
32
+ writeFileSync(opts.output, JSON.stringify(output, null, 2));
33
+
34
+ for (const warning of output.warnings ?? []) {
35
+ console.warn(`Warning: ${warning}`);
36
+ }
37
+
38
+ console.log(`\nDone. Output -> ${opts.output}`);
39
+ console.log('Load this file in the Figma plugin, or use the plugin HTML flow with the local server.\n');
40
+ }
41
+
42
+ run().catch((err) => {
43
+ console.error('Conversion failed:', err);
44
+ process.exit(1);
45
+ });
scripts/server.js CHANGED
@@ -1,173 +1,173 @@
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
-
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.MORPHUS_PORT ?? '3210', 10);
12
- const HOST = process.env.HOST ?? '0.0.0.0';
13
- const jobs = new Map();
14
-
15
- const server = http.createServer(async (req, res) => {
16
- setCors(res);
17
-
18
- if (req.method === 'OPTIONS') {
19
- res.writeHead(204);
20
- res.end();
21
- return;
22
- }
23
-
24
- if (req.method === 'GET' && req.url === '/health') {
25
- res.writeHead(200, { 'Content-Type': 'application/json' });
26
- res.end(JSON.stringify({ ok: true, host: HOST, port: PORT }));
27
- return;
28
- }
29
-
30
- if (req.method === 'POST' && req.url === '/jobs') {
31
- try {
32
- const body = await readJsonBody(req);
33
- if (!body.html || typeof body.html !== 'string') {
34
- throw new Error('`html` is required.');
35
- }
36
-
37
- const jobId = randomUUID();
38
- jobs.set(jobId, {
39
- state: 'queued',
40
- progress: 0,
41
- message: 'Queued',
42
- result: null,
43
- error: null,
44
- });
45
-
46
- runJob(jobId, body).catch((error) => {
47
- setJobError(jobId, error);
48
- });
49
-
50
- res.writeHead(202, { 'Content-Type': 'application/json' });
51
- res.end(JSON.stringify({ jobId }));
52
- return;
53
- } catch (error) {
54
- res.writeHead(400, { 'Content-Type': 'application/json' });
55
- res.end(JSON.stringify({ error: error.message }));
56
- return;
57
- }
58
- }
59
-
60
- if (req.method === 'GET' && req.url && req.url.startsWith('/jobs/')) {
61
- const jobId = req.url.split('/').pop();
62
- const job = jobs.get(jobId);
63
- if (!job) {
64
- res.writeHead(404, { 'Content-Type': 'application/json' });
65
- res.end(JSON.stringify({ error: 'Job not found' }));
66
- return;
67
- }
68
-
69
- res.writeHead(200, { 'Content-Type': 'application/json' });
70
- res.end(JSON.stringify(job));
71
- return;
72
- }
73
-
74
- if (req.method === 'POST' && req.url === '/convert') {
75
- try {
76
- const body = await readJsonBody(req);
77
- if (!body.html || typeof body.html !== 'string') {
78
- throw new Error('`html` is required.');
79
- }
80
-
81
- const result = await convertHtmlString(body.html, {
82
- sourceName: body.sourceName || 'inline.html',
83
- baseUrl: body.baseUrl || null,
84
- viewport: {
85
- width: body.viewport?.width,
86
- height: body.viewport?.height,
87
- },
88
- });
89
-
90
- res.writeHead(200, { 'Content-Type': 'application/json' });
91
- res.end(JSON.stringify(result));
92
- return;
93
- } catch (error) {
94
- res.writeHead(400, { 'Content-Type': 'application/json' });
95
- res.end(JSON.stringify({ error: error.message }));
96
- return;
97
- }
98
- }
99
-
100
- res.writeHead(404, { 'Content-Type': 'application/json' });
101
- res.end(JSON.stringify({ error: 'Not found' }));
102
- });
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) {
110
- setJob(jobId, 'running', 1, 'Starting conversion...');
111
-
112
- const result = await convertHtmlString(body.html, {
113
- sourceName: body.sourceName || 'inline.html',
114
- baseUrl: body.baseUrl || null,
115
- viewport: {
116
- width: body.viewport && body.viewport.width,
117
- height: body.viewport && body.viewport.height,
118
- },
119
- onProgress: (progress, message) => {
120
- setJob(jobId, 'running', progress, message);
121
- },
122
- });
123
-
124
- setJob(jobId, 'done', 100, 'Done', result);
125
- }
126
-
127
- function setJob(jobId, state, progress, message, result) {
128
- jobs.set(jobId, {
129
- state: state,
130
- progress: progress,
131
- message: message,
132
- result: result || null,
133
- error: null,
134
- });
135
- }
136
-
137
- function setJobError(jobId, error) {
138
- jobs.set(jobId, {
139
- state: 'error',
140
- progress: 100,
141
- message: 'Conversion failed',
142
- result: null,
143
- error: error && error.message ? error.message : String(error),
144
- });
145
- }
146
-
147
- function setCors(res) {
148
- res.setHeader('Access-Control-Allow-Origin', '*');
149
- res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
150
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
151
- }
152
-
153
- function readJsonBody(req) {
154
- return new Promise((resolve, reject) => {
155
- let raw = '';
156
- req.setEncoding('utf8');
157
- req.on('data', (chunk) => {
158
- raw += chunk;
159
- if (raw.length > 10 * 1024 * 1024) {
160
- reject(new Error('Request body too large.'));
161
- req.destroy();
162
- }
163
- });
164
- req.on('end', () => {
165
- try {
166
- resolve(JSON.parse(raw || '{}'));
167
- } catch {
168
- reject(new Error('Invalid JSON body.'));
169
- }
170
- });
171
- req.on('error', reject);
172
- });
173
- }
 
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
+
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.MORPHUS_PORT ?? '3210', 10);
12
+ const HOST = process.env.HOST ?? '0.0.0.0';
13
+ const jobs = new Map();
14
+
15
+ const server = http.createServer(async (req, res) => {
16
+ setCors(res);
17
+
18
+ if (req.method === 'OPTIONS') {
19
+ res.writeHead(204);
20
+ res.end();
21
+ return;
22
+ }
23
+
24
+ if (req.method === 'GET' && req.url === '/health') {
25
+ res.writeHead(200, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({ ok: true, host: HOST, port: PORT }));
27
+ return;
28
+ }
29
+
30
+ if (req.method === 'POST' && req.url === '/jobs') {
31
+ try {
32
+ const body = await readJsonBody(req);
33
+ if (!body.html || typeof body.html !== 'string') {
34
+ throw new Error('`html` is required.');
35
+ }
36
+
37
+ const jobId = randomUUID();
38
+ jobs.set(jobId, {
39
+ state: 'queued',
40
+ progress: 0,
41
+ message: 'Queued',
42
+ result: null,
43
+ error: null,
44
+ });
45
+
46
+ runJob(jobId, body).catch((error) => {
47
+ setJobError(jobId, error);
48
+ });
49
+
50
+ res.writeHead(202, { 'Content-Type': 'application/json' });
51
+ res.end(JSON.stringify({ jobId }));
52
+ return;
53
+ } catch (error) {
54
+ res.writeHead(400, { 'Content-Type': 'application/json' });
55
+ res.end(JSON.stringify({ error: error.message }));
56
+ return;
57
+ }
58
+ }
59
+
60
+ if (req.method === 'GET' && req.url && req.url.startsWith('/jobs/')) {
61
+ const jobId = req.url.split('/').pop();
62
+ const job = jobs.get(jobId);
63
+ if (!job) {
64
+ res.writeHead(404, { 'Content-Type': 'application/json' });
65
+ res.end(JSON.stringify({ error: 'Job not found' }));
66
+ return;
67
+ }
68
+
69
+ res.writeHead(200, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify(job));
71
+ return;
72
+ }
73
+
74
+ if (req.method === 'POST' && req.url === '/convert') {
75
+ try {
76
+ const body = await readJsonBody(req);
77
+ if (!body.html || typeof body.html !== 'string') {
78
+ throw new Error('`html` is required.');
79
+ }
80
+
81
+ const result = await convertHtmlString(body.html, {
82
+ sourceName: body.sourceName || 'inline.html',
83
+ baseUrl: body.baseUrl || null,
84
+ viewport: {
85
+ width: body.viewport?.width,
86
+ height: body.viewport?.height,
87
+ },
88
+ });
89
+
90
+ res.writeHead(200, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify(result));
92
+ return;
93
+ } catch (error) {
94
+ res.writeHead(400, { 'Content-Type': 'application/json' });
95
+ res.end(JSON.stringify({ error: error.message }));
96
+ return;
97
+ }
98
+ }
99
+
100
+ res.writeHead(404, { 'Content-Type': 'application/json' });
101
+ res.end(JSON.stringify({ error: 'Not found' }));
102
+ });
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) {
110
+ setJob(jobId, 'running', 1, 'Starting conversion...');
111
+
112
+ const result = await convertHtmlString(body.html, {
113
+ sourceName: body.sourceName || 'inline.html',
114
+ baseUrl: body.baseUrl || null,
115
+ viewport: {
116
+ width: body.viewport && body.viewport.width,
117
+ height: body.viewport && body.viewport.height,
118
+ },
119
+ onProgress: (progress, message) => {
120
+ setJob(jobId, 'running', progress, message);
121
+ },
122
+ });
123
+
124
+ setJob(jobId, 'done', 100, 'Done', result);
125
+ }
126
+
127
+ function setJob(jobId, state, progress, message, result) {
128
+ jobs.set(jobId, {
129
+ state: state,
130
+ progress: progress,
131
+ message: message,
132
+ result: result || null,
133
+ error: null,
134
+ });
135
+ }
136
+
137
+ function setJobError(jobId, error) {
138
+ jobs.set(jobId, {
139
+ state: 'error',
140
+ progress: 100,
141
+ message: 'Conversion failed',
142
+ result: null,
143
+ error: error && error.message ? error.message : String(error),
144
+ });
145
+ }
146
+
147
+ function setCors(res) {
148
+ res.setHeader('Access-Control-Allow-Origin', '*');
149
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
150
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
151
+ }
152
+
153
+ function readJsonBody(req) {
154
+ return new Promise((resolve, reject) => {
155
+ let raw = '';
156
+ req.setEncoding('utf8');
157
+ req.on('data', (chunk) => {
158
+ raw += chunk;
159
+ if (raw.length > 10 * 1024 * 1024) {
160
+ reject(new Error('Request body too large.'));
161
+ req.destroy();
162
+ }
163
+ });
164
+ req.on('end', () => {
165
+ try {
166
+ resolve(JSON.parse(raw || '{}'));
167
+ } catch {
168
+ reject(new Error('Invalid JSON body.'));
169
+ }
170
+ });
171
+ req.on('error', reject);
172
+ });
173
+ }
scripts/update-snapshot.js CHANGED
@@ -1,20 +1,20 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Regenerates the deterministic baseline snapshot used in tests.
4
- */
5
-
6
- import { writeFileSync, mkdirSync } from 'fs';
7
- import { dirname } from 'path';
8
- import { convertHtmlFile } from '../src/pipeline/convert.js';
9
-
10
- const SNAPSHOT_PATH = 'tests/vela/expected-snapshot.json';
11
- const INPUT_PATH = './tests/vela/input.html';
12
-
13
- const output = await convertHtmlFile(INPUT_PATH, {
14
- viewport: { width: 1440, height: 900 },
15
- });
16
-
17
- mkdirSync(dirname(SNAPSHOT_PATH), { recursive: true });
18
- writeFileSync(SNAPSHOT_PATH, JSON.stringify(output, null, 2));
19
-
20
- console.log(`Updated snapshot at ${SNAPSHOT_PATH}`);
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regenerates the deterministic baseline snapshot used in tests.
4
+ */
5
+
6
+ import { writeFileSync, mkdirSync } from 'fs';
7
+ import { dirname } from 'path';
8
+ import { convertHtmlFile } from '../src/pipeline/convert.js';
9
+
10
+ const SNAPSHOT_PATH = 'tests/landing-page/expected-snapshot.json';
11
+ const INPUT_PATH = './tests/landing-page/input.html';
12
+
13
+ const output = await convertHtmlFile(INPUT_PATH, {
14
+ viewport: { width: 1440, height: 900 },
15
+ });
16
+
17
+ mkdirSync(dirname(SNAPSHOT_PATH), { recursive: true });
18
+ writeFileSync(SNAPSHOT_PATH, JSON.stringify(output, null, 2));
19
+
20
+ console.log(`Updated snapshot at ${SNAPSHOT_PATH}`);
src/core/dom-tree.js CHANGED
@@ -1,43 +1,43 @@
1
- /**
2
- * src/core/dom-tree.js
3
- * Utilities for walking and querying the intermediate DOM tree JSON.
4
- */
5
-
6
- /**
7
- * Depth-first walk of the dom tree.
8
- * @param {object} node
9
- * @param {(node: object, depth: number) => void} fn
10
- */
11
- export function walk(node, fn, depth = 0) {
12
- fn(node, depth);
13
- for (const child of node.children ?? []) {
14
- walk(child, fn, depth + 1);
15
- }
16
- }
17
-
18
- /**
19
- * Collect all nodes matching a predicate.
20
- */
21
- export function findAll(node, predicate) {
22
- const results = [];
23
- walk(node, (n) => { if (predicate(n)) results.push(n); });
24
- return results;
25
- }
26
-
27
- /**
28
- * Find all nodes that use CSS grid.
29
- */
30
- export function findGridNodes(domTree) {
31
- return findAll(domTree, (n) => n.computed?.display === 'grid');
32
- }
33
-
34
- /**
35
- * Find all nodes with hover-relevant classes (heuristic).
36
- */
37
- export function findInteractiveNodes(domTree) {
38
- const interactiveTags = new Set(['button', 'a', 'input', 'select', 'textarea']);
39
- return findAll(domTree, (n) =>
40
- interactiveTags.has(n.tag) ||
41
- n.classList?.some(c => c.includes('btn') || c.includes('card') || c.includes('link'))
42
- );
43
- }
 
1
+ /**
2
+ * src/core/dom-tree.js
3
+ * Utilities for walking and querying the intermediate DOM tree JSON.
4
+ */
5
+
6
+ /**
7
+ * Depth-first walk of the dom tree.
8
+ * @param {object} node
9
+ * @param {(node: object, depth: number) => void} fn
10
+ */
11
+ export function walk(node, fn, depth = 0) {
12
+ fn(node, depth);
13
+ for (const child of node.children ?? []) {
14
+ walk(child, fn, depth + 1);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Collect all nodes matching a predicate.
20
+ */
21
+ export function findAll(node, predicate) {
22
+ const results = [];
23
+ walk(node, (n) => { if (predicate(n)) results.push(n); });
24
+ return results;
25
+ }
26
+
27
+ /**
28
+ * Find all nodes that use CSS grid.
29
+ */
30
+ export function findGridNodes(domTree) {
31
+ return findAll(domTree, (n) => n.computed?.display === 'grid');
32
+ }
33
+
34
+ /**
35
+ * Find all nodes with hover-relevant classes (heuristic).
36
+ */
37
+ export function findInteractiveNodes(domTree) {
38
+ const interactiveTags = new Set(['button', 'a', 'input', 'select', 'textarea']);
39
+ return findAll(domTree, (n) =>
40
+ interactiveTags.has(n.tag) ||
41
+ n.classList?.some(c => c.includes('btn') || c.includes('card') || c.includes('link'))
42
+ );
43
+ }
src/core/extractor.js CHANGED
@@ -16,7 +16,9 @@ import { pathToFileURL } from 'url';
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);
@@ -31,7 +33,9 @@ export async function extractFromFile(filePath, { width = 1440, height = 900 } =
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
 
@@ -53,6 +57,25 @@ async function extractFromPage(page) {
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
 
@@ -60,30 +83,30 @@ async function stabilizePage(page) {
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
 
@@ -135,115 +158,115 @@ async function stabilizePage(page) {
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
  }
@@ -273,7 +296,7 @@ async function waitForCanvasPaint(page) {
273
  }
274
 
275
  const now = performance.now();
276
- const state = window.__morphusCanvasCaptureState || {
277
  startedAt: now,
278
  lastSignature: '',
279
  stableCount: 0,
@@ -293,7 +316,7 @@ async function waitForCanvasPaint(page) {
293
  state.stableCount = 0;
294
  }
295
 
296
- window.__morphusCanvasCaptureState = state;
297
  return state.stableCount >= 2 && now - state.startedAt >= 500;
298
 
299
  function captureCanvasSnapshot(canvas) {
@@ -310,7 +333,7 @@ async function waitForCanvasPaint(page) {
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)}`,
@@ -377,36 +400,36 @@ function escapeHtmlAttribute(value) {
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([
382
- 'a',
383
- 'abbr',
384
- 'b',
385
- 'bdi',
386
- 'bdo',
387
- 'cite',
388
- 'code',
389
- 'data',
390
- 'dfn',
391
- 'em',
392
- 'i',
393
- 'kbd',
394
- 'label',
395
- 'mark',
396
- 'q',
397
- 's',
398
- 'samp',
399
- 'small',
400
- 'span',
401
- 'strong',
402
- 'sub',
403
- 'sup',
404
- 'time',
405
- 'u',
406
- 'var',
407
- 'br',
408
- 'wbr',
409
- ]);
410
  const TEXT_INPUT_TYPES = new Set([
411
  '',
412
  'date',
@@ -440,10 +463,10 @@ function walkDOMInBrowser() {
440
  if (isVisuallyHiddenElement(cs)) return null;
441
 
442
  const rawText = normalizeTextContent(el.innerText || el.textContent || '');
443
- const hasVisualBox =
444
- !isTransparentColor(cs.backgroundColor) ||
445
- cs.backgroundImage !== 'none' ||
446
- cs.borderStyle !== 'none' ||
447
  parseFloat(cs.borderTopWidth) > 0 ||
448
  parseFloat(cs.borderRightWidth) > 0 ||
449
  parseFloat(cs.borderBottomWidth) > 0 ||
@@ -451,53 +474,61 @@ function walkDOMInBrowser() {
451
  parseFloat(cs.paddingTop) > 0 ||
452
  parseFloat(cs.paddingRight) > 0 ||
453
  parseFloat(cs.paddingBottom) > 0 ||
454
- parseFloat(cs.paddingLeft) > 0 ||
455
- cs.boxShadow !== 'none';
456
-
457
- const inlineVisualFragments = extractInlineVisualFragments(el, tag, cs, csBefore, csAfter, rect, rawText, hasVisualBox);
458
- if (inlineVisualFragments) {
459
- return inlineVisualFragments;
460
- }
461
-
462
- const inlineTextFragments = extractInlineTextFragments(el, tag, cs, csBefore, csAfter, rect, rawText, hasVisualBox);
463
- if (inlineTextFragments) {
464
- return inlineTextFragments;
465
- }
466
-
467
- const hasOnlyInlineTextChildren = Boolean(rawText) && Array.from(el.children).length > 0 && Array.from(el.children).every((child) => isInlineTextChild(child));
468
- const isTextContainer = Boolean(rawText)
469
- && !hasVisualBox
470
- && !hasRenderablePseudo(csBefore)
471
- && !hasRenderablePseudo(csAfter)
 
472
  && canCollapseToTextContainer(el, tag, cs, hasOnlyInlineTextChildren);
473
 
474
- const beforeData = extractPseudoElementData(el, tag, cs, csBefore, 'before');
475
- const afterData = extractPseudoElementData(el, tag, cs, csAfter, 'after');
476
- const formControl = extractFormControlData(el, tag, cs);
477
- const nodeText = formControl ? null : rawText;
478
- const textData = nodeText ? extractTextData(el) : null;
479
- const renderedText = textData?.text || nodeText;
480
- const svgMarkup = isSvg ? serializeSvgElement(el, rect) : null;
481
- const imageData = isImage
482
- ? extractImageData(el)
483
- : isCanvas
484
- ? extractCanvasImageData(el)
485
- : null;
486
-
487
- const children = isSvg || isImage || isCanvas || isTextContainer || formControl
488
- ? []
489
- : Array.from(el.childNodes)
490
- .map((child) => getChildNode(child, el, cs, depth + 1))
491
- .filter(Boolean);
492
 
493
  return {
494
- tag,
495
- id: el.id || null,
496
- classList: Array.from(el.classList),
497
- text: renderedText || null,
498
- textRuns: textData?.runs || [],
499
  isTextContainer,
500
- rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
 
 
 
 
 
 
 
501
  computed: extractRelevantStyles(cs),
502
  ...(formControl ? { formControl } : {}),
503
  ...(svgMarkup ? { svgMarkup } : {}),
@@ -506,449 +537,449 @@ function walkDOMInBrowser() {
506
  before: beforeData,
507
  after: afterData,
508
  },
509
- children,
510
- };
511
- }
512
-
513
- function extractInlineVisualFragments(el, tag, cs, csBefore, csAfter, rect, rawText, hasVisualBox) {
514
- if (!shouldSplitInlineVisualElement(el, tag, cs, csBefore, csAfter, rawText, hasVisualBox)) {
515
- return null;
516
- }
517
-
518
- const fragmentRects = getElementClientRects(el);
519
- if (fragmentRects.length <= 1) {
520
- return null;
521
- }
522
-
523
- const lines = collectInlineVisualTextLines(el, fragmentRects);
524
- if (!lines.some((line) => line.text)) {
525
- return null;
526
- }
527
-
528
- const wrapperComputed = makeTransparentInlineWrapperStyles(cs, rect);
529
- const fragmentNodes = fragmentRects
530
- .map((fragmentRect, index) => buildInlineVisualFragmentNode(el, tag, cs, fragmentRect, lines[index]))
531
- .filter(Boolean);
532
-
533
- if (fragmentNodes.length <= 1) {
534
- return null;
535
- }
536
-
537
- return {
538
- tag,
539
- id: el.id || null,
540
- classList: Array.from(el.classList),
541
- text: null,
542
- textRuns: [],
543
- isTextContainer: false,
544
- _inlineFragmentGroup: true,
545
- rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
546
- computed: wrapperComputed,
547
- pseudo: {
548
- before: null,
549
- after: null,
550
- },
551
- children: fragmentNodes,
552
- };
553
- }
554
-
555
- function extractInlineTextFragments(el, tag, cs, csBefore, csAfter, rect, rawText, hasVisualBox) {
556
- if (!shouldSplitInlineTextElement(el, tag, cs, csBefore, csAfter, rawText, hasVisualBox)) {
557
- return null;
558
- }
559
-
560
- const fragmentRects = getElementClientRects(el);
561
- if (fragmentRects.length <= 1) {
562
- return null;
563
- }
564
-
565
- const lines = collectInlineVisualTextLines(el, fragmentRects);
566
- const fragmentNodes = lines
567
- .map((line) => buildInlineTextFragmentNode(el, cs, line))
568
- .filter(Boolean);
569
-
570
- if (fragmentNodes.length <= 1) {
571
- return null;
572
- }
573
-
574
- return {
575
- tag,
576
- id: el.id || null,
577
- classList: Array.from(el.classList),
578
- text: null,
579
- textRuns: [],
580
- isTextContainer: false,
581
- _inlineTextFragmentGroup: true,
582
- rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
583
- computed: makeTransparentInlineWrapperStyles(cs, rect),
584
- pseudo: {
585
- before: null,
586
- after: null,
587
- },
588
- children: fragmentNodes,
589
- };
590
- }
591
-
592
- function shouldSplitInlineTextElement(el, tag, cs, csBefore, csAfter, rawText, hasVisualBox) {
593
- if (hasVisualBox || !rawText || !INLINE_TAGS.has(tag)) {
594
- return false;
595
- }
596
-
597
- if (cs.display !== 'inline' || cs.position !== 'static') {
598
- return false;
599
- }
600
-
601
- if (hasRenderablePseudo(csBefore) || hasRenderablePseudo(csAfter)) {
602
- return false;
603
- }
604
-
605
- return getElementClientRects(el).length > 1;
606
- }
607
-
608
- function buildInlineTextFragmentNode(el, cs, line) {
609
- if (!line?.text || !line.textRect) {
610
- return null;
611
- }
612
-
613
- const computed = extractRelevantStyles(cs);
614
- computed.display = 'inline';
615
- computed.position = 'static';
616
- computed.width = `${line.textRect.width}px`;
617
- computed.height = `${line.textRect.height}px`;
618
- computed.minWidth = '0px';
619
- computed.minHeight = '0px';
620
-
621
- return {
622
- tag: el.tagName.toLowerCase(),
623
- id: null,
624
- classList: Array.from(el.classList),
625
- text: line.text,
626
- textRuns: line.runs,
627
- isTextContainer: true,
628
- _inlineTextFragment: true,
629
- rect: line.textRect,
630
- computed,
631
- pseudo: {
632
- before: null,
633
- after: null,
634
- },
635
- children: [],
636
- };
637
- }
638
-
639
- function shouldSplitInlineVisualElement(el, tag, cs, csBefore, csAfter, rawText, hasVisualBox) {
640
- if (!hasVisualBox || !rawText || !INLINE_TAGS.has(tag)) {
641
- return false;
642
- }
643
-
644
- if (cs.display !== 'inline' || cs.position !== 'static') {
645
- return false;
646
- }
647
-
648
- if (hasRenderablePseudo(csBefore) || hasRenderablePseudo(csAfter)) {
649
- return false;
650
- }
651
-
652
- return getElementClientRects(el).length > 1;
653
- }
654
-
655
- function getElementClientRects(el) {
656
- return Array.from(el.getClientRects())
657
- .map(rectToPlainObject)
658
- .filter((rect) => rect.width > 0 && rect.height > 0);
659
- }
660
-
661
- function buildInlineVisualFragmentNode(el, tag, cs, fragmentRect, line) {
662
- const computed = extractRelevantStyles(cs);
663
- computed.display = 'inline';
664
- computed.position = 'static';
665
- computed.width = `${fragmentRect.width}px`;
666
- computed.height = `${fragmentRect.height}px`;
667
- computed.minWidth = '0px';
668
- computed.minHeight = '0px';
669
-
670
- const textNode = line?.text
671
- ? buildInlineFragmentTextNode(cs, line)
672
- : null;
673
-
674
- return {
675
- tag,
676
- id: null,
677
- classList: Array.from(el.classList),
678
- text: null,
679
- textRuns: [],
680
- isTextContainer: false,
681
- _inlineFragment: true,
682
- rect: fragmentRect,
683
- computed,
684
- pseudo: {
685
- before: null,
686
- after: null,
687
- },
688
- children: textNode ? [textNode] : [],
689
- };
690
- }
691
-
692
- function buildInlineFragmentTextNode(cs, line) {
693
- const computed = extractRelevantStyles(cs);
694
- computed.display = 'inline';
695
- computed.position = 'static';
696
- computed.width = `${line.textRect.width}px`;
697
- computed.height = `${line.textRect.height}px`;
698
- computed.minWidth = '0px';
699
- computed.minHeight = '0px';
700
- computed.backgroundColor = 'rgba(0, 0, 0, 0)';
701
- computed.backgroundImage = 'none';
702
- computed.paddingTop = '0px';
703
- computed.paddingRight = '0px';
704
- computed.paddingBottom = '0px';
705
- computed.paddingLeft = '0px';
706
- computed.border = '0px none rgba(0, 0, 0, 0)';
707
- computed.borderWidth = '0px';
708
- computed.borderStyle = 'none';
709
- computed.boxShadow = 'none';
710
-
711
- return {
712
- tag: 'span',
713
- id: null,
714
- classList: [],
715
- text: line.text,
716
- textRuns: line.runs,
717
- isTextContainer: true,
718
- _inlineFragmentText: true,
719
- rect: line.textRect,
720
- computed,
721
- pseudo: {
722
- before: null,
723
- after: null,
724
- },
725
- children: [],
726
- };
727
- }
728
-
729
- function collectInlineVisualTextLines(el, fragmentRects) {
730
- const lines = fragmentRects.map((fragmentRect) => ({
731
- fragmentRect,
732
- text: '',
733
- runs: [],
734
- textRect: null,
735
- }));
736
- let pendingWhitespace = false;
737
- let previousLineIndex = null;
738
-
739
- function walkInlineText(node, styleEl) {
740
- if (node.nodeType === Node.TEXT_NODE) {
741
- collectTextNodeLines(node, styleEl);
742
- return;
743
- }
744
-
745
- if (node.nodeType !== Node.ELEMENT_NODE) {
746
- return;
747
- }
748
-
749
- const element = node;
750
- if (element.tagName.toLowerCase() === 'br') {
751
- pendingWhitespace = false;
752
- previousLineIndex = null;
753
- return;
754
- }
755
-
756
- for (const child of element.childNodes) {
757
- walkInlineText(child, element);
758
- }
759
- }
760
-
761
- function collectTextNodeLines(textNode, styleEl) {
762
- const text = textNode.textContent || '';
763
- const tokenPattern = /\s+|\S+/g;
764
- let match;
765
-
766
- while ((match = tokenPattern.exec(text)) !== null) {
767
- const token = match[0];
768
- if (/^\s+$/.test(token)) {
769
- pendingWhitespace = true;
770
- continue;
771
- }
772
-
773
- appendMeasuredToken(textNode, match.index, match.index + token.length, token, styleEl);
774
- }
775
- }
776
-
777
- function appendMeasuredToken(textNode, start, end, token, styleEl) {
778
- const tokenRects = measureTextRangeClientRects(textNode, start, end);
779
- if (tokenRects.length <= 1) {
780
- const rect = tokenRects[0];
781
- if (!rect) return;
782
- appendLineText(findBestFragmentIndex(rect, fragmentRects), token, rect, styleEl);
783
- return;
784
- }
785
-
786
- let segment = '';
787
- let segmentLineIndex = null;
788
- let segmentRect = null;
789
-
790
- for (let offset = start; offset < end; offset++) {
791
- const char = (textNode.textContent || '').slice(offset, offset + 1);
792
- const charRect = measureTextRangeClientRects(textNode, offset, offset + 1)[0];
793
- if (!charRect) continue;
794
-
795
- const lineIndex = findBestFragmentIndex(charRect, fragmentRects);
796
- if (segment && lineIndex !== segmentLineIndex) {
797
- appendLineText(segmentLineIndex, segment, segmentRect, styleEl);
798
- segment = '';
799
- segmentRect = null;
800
- }
801
-
802
- segment += char;
803
- segmentLineIndex = lineIndex;
804
- segmentRect = unionTwoRects(segmentRect, charRect);
805
- }
806
-
807
- if (segment) {
808
- appendLineText(segmentLineIndex, segment, segmentRect, styleEl);
809
- }
810
- }
811
-
812
- function appendLineText(lineIndex, rawToken, rect, styleEl) {
813
- const line = lines[lineIndex];
814
- if (!line || !rawToken || !rect) {
815
- return;
816
- }
817
-
818
- const normalizedToken = normalizeTextFragment(rawToken);
819
- if (!normalizedToken) {
820
- return;
821
- }
822
-
823
- const prefix = pendingWhitespace && previousLineIndex === lineIndex && line.text ? ' ' : '';
824
- const text = prefix + normalizedToken;
825
- line.text += text;
826
- line.runs.push({
827
- text,
828
- lineIndex: 0,
829
- computed: extractTextRunStyles(window.getComputedStyle(styleEl || el)),
830
- });
831
- line.textRect = unionTwoRects(line.textRect, rect);
832
- pendingWhitespace = false;
833
- previousLineIndex = lineIndex;
834
- }
835
-
836
- for (const child of el.childNodes) {
837
- walkInlineText(child, el);
838
- }
839
-
840
- for (const line of lines) {
841
- if (!line.textRect) {
842
- line.textRect = line.fragmentRect;
843
- }
844
- line.text = normalizeTextContent(line.text);
845
- }
846
-
847
- return lines;
848
- }
849
-
850
- function measureTextRangeClientRects(textNode, start, end) {
851
- const range = document.createRange();
852
- range.setStart(textNode, start);
853
- range.setEnd(textNode, end);
854
- return Array.from(range.getClientRects())
855
- .map(rectToPlainObject)
856
- .filter((rect) => rect.width > 0 && rect.height > 0);
857
- }
858
-
859
- function findBestFragmentIndex(rect, fragmentRects) {
860
- let bestIndex = 0;
861
- let bestScore = -1;
862
- const centerY = rect.y + rect.height / 2;
863
-
864
- for (let index = 0; index < fragmentRects.length; index++) {
865
- const fragment = fragmentRects[index];
866
- const overlapX = Math.max(0, Math.min(rect.x + rect.width, fragment.x + fragment.width) - Math.max(rect.x, fragment.x));
867
- const overlapY = Math.max(0, Math.min(rect.y + rect.height, fragment.y + fragment.height) - Math.max(rect.y, fragment.y));
868
- const area = overlapX * overlapY;
869
- const yDistance = Math.abs(centerY - (fragment.y + fragment.height / 2));
870
- const score = area > 0 ? area : -yDistance;
871
- if (score > bestScore) {
872
- bestScore = score;
873
- bestIndex = index;
874
- }
875
- }
876
-
877
- return bestIndex;
878
- }
879
-
880
- function makeTransparentInlineWrapperStyles(cs, rect) {
881
- const computed = extractRelevantStyles(cs);
882
- computed.display = 'inline';
883
- computed.position = 'static';
884
- computed.width = `${rect.width}px`;
885
- computed.height = `${rect.height}px`;
886
- computed.minWidth = '0px';
887
- computed.minHeight = '0px';
888
- computed.paddingTop = '0px';
889
- computed.paddingRight = '0px';
890
- computed.paddingBottom = '0px';
891
- computed.paddingLeft = '0px';
892
- computed.backgroundColor = 'rgba(0, 0, 0, 0)';
893
- computed.backgroundImage = 'none';
894
- computed.borderRadius = '0px';
895
- computed.borderTopLeftRadius = '0px';
896
- computed.borderTopRightRadius = '0px';
897
- computed.borderBottomRightRadius = '0px';
898
- computed.borderBottomLeftRadius = '0px';
899
- computed.border = '0px none rgba(0, 0, 0, 0)';
900
- computed.borderWidth = '0px';
901
- computed.borderColor = 'rgba(0, 0, 0, 0)';
902
- computed.borderStyle = 'none';
903
- computed.borderTopWidth = '0px';
904
- computed.borderRightWidth = '0px';
905
- computed.borderBottomWidth = '0px';
906
- computed.borderLeftWidth = '0px';
907
- computed.borderTopColor = 'rgba(0, 0, 0, 0)';
908
- computed.borderRightColor = 'rgba(0, 0, 0, 0)';
909
- computed.borderBottomColor = 'rgba(0, 0, 0, 0)';
910
- computed.borderLeftColor = 'rgba(0, 0, 0, 0)';
911
- computed.borderTopStyle = 'none';
912
- computed.borderRightStyle = 'none';
913
- computed.borderBottomStyle = 'none';
914
- computed.borderLeftStyle = 'none';
915
- computed.boxShadow = 'none';
916
- return computed;
917
- }
918
-
919
- function rectToPlainObject(rect) {
920
- return {
921
- x: rect.x,
922
- y: rect.y,
923
- width: rect.width,
924
- height: rect.height,
925
- };
926
- }
927
-
928
- function unionTwoRects(a, b) {
929
- if (!a) return b ? { x: b.x, y: b.y, width: b.width, height: b.height } : null;
930
- if (!b) return { x: a.x, y: a.y, width: a.width, height: a.height };
931
-
932
- const left = Math.min(a.x, b.x);
933
- const top = Math.min(a.y, b.y);
934
- const right = Math.max(a.x + a.width, b.x + b.width);
935
- const bottom = Math.max(a.y + a.height, b.y + b.height);
936
- return {
937
- x: left,
938
- y: top,
939
- width: Math.max(right - left, 0),
940
- height: Math.max(bottom - top, 0),
941
- };
942
- }
943
-
944
- function extractFormControlData(el, tag, computedStyles) {
945
- if (tag === 'select') {
946
- return extractSelectControlData(el);
947
- }
948
-
949
- if (tag !== 'input' && tag !== 'textarea') {
950
- return null;
951
- }
952
 
953
  const type = tag === 'input'
954
  ? String(el.getAttribute('type') || 'text').trim().toLowerCase()
@@ -975,34 +1006,34 @@ function walkDOMInBrowser() {
975
  type,
976
  value,
977
  placeholder,
978
- ...(placeholderComputed ? { placeholderComputed } : {}),
979
- };
980
- }
981
-
982
- function extractSelectControlData(el) {
983
- const selectedOptions = Array.from(el.selectedOptions || []);
984
- const renderedOptions = selectedOptions.length > 0
985
- ? selectedOptions
986
- : [el.options && el.selectedIndex >= 0 ? el.options[el.selectedIndex] : null].filter(Boolean);
987
- const renderedText = normalizeFormControlText(
988
- renderedOptions
989
- .map((option) => option.label || option.textContent || '')
990
- .filter(Boolean)
991
- .join(el.multiple ? '\n' : ' ')
992
- );
993
-
994
- if (!renderedText) {
995
- return null;
996
- }
997
-
998
- const size = Number.parseInt(el.getAttribute('size') || '1', 10);
999
- return {
1000
- type: 'select',
1001
- value: renderedText,
1002
- optionValue: renderedOptions[0] ? renderedOptions[0].value : el.value,
1003
- hasChevron: !el.multiple && (!Number.isFinite(size) || size <= 1),
1004
- };
1005
- }
1006
 
1007
  function extractPlaceholderStyles(el, fallbackStyles) {
1008
  try {
@@ -1081,6 +1112,7 @@ function walkDOMInBrowser() {
1081
  borderBottomStyle: cs.borderBottomStyle,
1082
  borderLeftStyle: cs.borderLeftStyle,
1083
  boxShadow: cs.boxShadow,
 
1084
  overflow: cs.overflow,
1085
  overflowX: cs.overflowX,
1086
  overflowY: cs.overflowY,
@@ -1095,16 +1127,18 @@ function walkDOMInBrowser() {
1095
  lineHeight: cs.lineHeight,
1096
  letterSpacing: cs.letterSpacing,
1097
  textAlign: cs.textAlign,
1098
- textTransform: cs.textTransform,
1099
- whiteSpace: cs.whiteSpace,
1100
- textOverflow: cs.textOverflow,
1101
- textDecoration: cs.textDecoration,
1102
- textDecorationLine: cs.textDecorationLine,
1103
- textDecorationStyle: cs.textDecorationStyle,
1104
- textDecorationColor: cs.textDecorationColor,
1105
- textDecorationThickness: cs.textDecorationThickness,
1106
- webkitTextStrokeWidth: cs.webkitTextStrokeWidth,
1107
- webkitTextStrokeColor: cs.webkitTextStrokeColor,
 
 
1108
  // Positioning
1109
  top: cs.top,
1110
  right: cs.right,
@@ -1133,8 +1167,22 @@ function walkDOMInBrowser() {
1133
  return null;
1134
  }
1135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1136
  return {
1137
- src,
1138
  alt: el.getAttribute('alt') || '',
1139
  naturalWidth: Number.isFinite(el.naturalWidth) ? el.naturalWidth : 0,
1140
  naturalHeight: Number.isFinite(el.naturalHeight) ? el.naturalHeight : 0,
@@ -1148,7 +1196,7 @@ function walkDOMInBrowser() {
1148
  return null;
1149
  }
1150
 
1151
- let src = String(el.__morphusCanvasCaptureSrc || '');
1152
  try {
1153
  src = src || el.toDataURL('image/png');
1154
  } catch (err) {
@@ -1172,28 +1220,28 @@ function walkDOMInBrowser() {
1172
  const pieces = [];
1173
  let lineIndex = 0;
1174
 
1175
- function pushText(text, styleSource) {
1176
- const normalized = normalizeTextFragment(text);
1177
- if (!normalized) return;
1178
- pieces.push(normalized);
1179
- runs.push({
1180
- text: normalized,
1181
- lineIndex,
1182
- computed: extractTextRunStyles(resolveTextRunComputedStyles(styleSource)),
1183
- });
1184
- }
1185
-
1186
- function pushPseudoText(element, pseudoType) {
1187
- const pseudoStyles = window.getComputedStyle(element, pseudoType);
1188
- const content = parseCssContent(pseudoStyles.content);
1189
- if (!content) return;
1190
- pushText(content, pseudoStyles);
1191
- }
1192
-
1193
- function walkText(node, styleEl) {
1194
- if (node.nodeType === Node.TEXT_NODE) {
1195
- pushText(node.textContent || '', styleEl);
1196
- return;
1197
  }
1198
 
1199
  if (node.nodeType !== Node.ELEMENT_NODE) return;
@@ -1204,16 +1252,16 @@ function walkDOMInBrowser() {
1204
  if (tagName === 'br') {
1205
  pieces.push('\n');
1206
  lineIndex++;
1207
- return;
1208
- }
1209
-
1210
- const nextStyleEl = element;
1211
- pushPseudoText(element, '::before');
1212
- for (const child of element.childNodes) {
1213
- walkText(child, nextStyleEl);
1214
- }
1215
- pushPseudoText(element, '::after');
1216
- }
1217
 
1218
  for (const child of el.childNodes) {
1219
  walkText(child, el);
@@ -1224,17 +1272,17 @@ function walkDOMInBrowser() {
1224
  .replace(/[ \t]*\n[ \t]*/g, '\n')
1225
  .replace(/[ \t]{2,}/g, ' ')
1226
  .trim();
1227
-
1228
- return { text, runs };
1229
- }
1230
-
1231
- function resolveTextRunComputedStyles(styleSource) {
1232
- if (styleSource && typeof styleSource.getPropertyValue === 'function') {
1233
- return styleSource;
1234
- }
1235
-
1236
- return window.getComputedStyle(styleSource);
1237
- }
1238
 
1239
  function extractTextRunStyles(cs) {
1240
  return {
@@ -1248,15 +1296,18 @@ function walkDOMInBrowser() {
1248
  letterSpacing: cs.letterSpacing,
1249
  textAlign: cs.textAlign,
1250
  textTransform: cs.textTransform,
1251
- color: cs.color,
1252
- opacity: cs.opacity,
1253
- textDecoration: cs.textDecoration,
1254
- textDecorationLine: cs.textDecorationLine,
1255
- textDecorationStyle: cs.textDecorationStyle,
1256
- textDecorationColor: cs.textDecorationColor,
1257
- textDecorationThickness: cs.textDecorationThickness,
1258
- webkitTextStrokeWidth: cs.webkitTextStrokeWidth,
1259
- webkitTextStrokeColor: cs.webkitTextStrokeColor,
 
 
 
1260
  };
1261
  }
1262
 
@@ -1285,7 +1336,7 @@ function walkDOMInBrowser() {
1285
  const cloneEl = cloneElements[index];
1286
  if (!sourceEl || !cloneEl) continue;
1287
 
1288
- cloneEl.removeAttribute('data-morphus-animated');
1289
  const cs = window.getComputedStyle(sourceEl);
1290
  const isRoot = index === 0;
1291
 
@@ -1337,51 +1388,51 @@ function walkDOMInBrowser() {
1337
  return String(Math.max(Math.round(number * 1000) / 1000, 1));
1338
  }
1339
 
1340
- function getChildNode(child, parentEl, parentStyles, depth) {
1341
- if (child.nodeType === Node.TEXT_NODE) {
1342
- return getDirectTextNode(child, parentEl, parentStyles);
1343
- }
1344
-
1345
- if (child.nodeType === Node.ELEMENT_NODE) {
1346
- const node = getNode(child, depth);
1347
- if (!node) {
1348
- return null;
1349
- }
1350
-
1351
- if (parentEl && parentStyles && clippingEnabled(parentStyles)) {
1352
- const parentRect = parentEl.getBoundingClientRect();
1353
- if (isClippedOutsideParent(node.rect, parentRect, parentStyles)) {
1354
- return null;
1355
- }
1356
- }
1357
-
1358
- return node;
1359
- }
1360
-
1361
- return null;
1362
- }
1363
-
1364
- function getDirectTextNode(textNode, parentEl, parentStyles) {
1365
- const normalizedText = normalizeTextFragment(textNode.textContent || '').trim();
1366
- if (!normalizedText) {
1367
- return null;
1368
- }
1369
 
1370
  const range = document.createRange();
1371
  range.selectNodeContents(textNode);
1372
- const rect = range.getBoundingClientRect();
1373
- if (rect.width === 0 && rect.height === 0) {
1374
- return null;
1375
- }
1376
-
1377
- if (parentEl && parentStyles && clippingEnabled(parentStyles)) {
1378
- const parentRect = parentEl.getBoundingClientRect();
1379
- if (isClippedOutsideParent(rect, parentRect, parentStyles)) {
1380
- return null;
1381
- }
1382
- }
1383
-
1384
- const computed = extractRelevantStyles(parentStyles);
1385
  computed.display = 'inline';
1386
  computed.position = 'static';
1387
  computed.width = `${rect.width}px`;
@@ -1551,7 +1602,7 @@ function walkDOMInBrowser() {
1551
  }
1552
 
1553
  const parentRect = el.getBoundingClientRect();
1554
- const rect = estimatePseudoTextRect(parentRect, parentStyles, pseudoStyles, pseudoType, content);
1555
  const transformedRect = applyPseudoTransformRect(rect, pseudoStyles.transform);
1556
  const finalRect = transformedRect || rect;
1557
 
@@ -1597,86 +1648,87 @@ function walkDOMInBrowser() {
1597
  parseFloat(cs.borderRightWidth) > 0 ||
1598
  parseFloat(cs.borderBottomWidth) > 0 ||
1599
  parseFloat(cs.borderLeftWidth) > 0 ||
1600
- cs.boxShadow !== 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1601
  }
1602
 
1603
- function estimatePseudoTextRect(parentRect, parentStyles, pseudoStyles, pseudoType, content = '') {
1604
- const metrics = content ? measurePseudoTextMetrics(content, pseudoStyles) : null;
1605
- const width = Math.max(parseCssPx(pseudoStyles.width), metrics?.width || 0);
1606
- const height = Math.max(
1607
- parseCssPx(pseudoStyles.height) || parseCssPx(pseudoStyles.lineHeight) || parseCssPx(pseudoStyles.fontSize),
1608
- metrics?.height || 0
1609
- );
1610
- const position = pseudoStyles.position;
1611
-
1612
- if (position === 'absolute' || position === 'fixed') {
1613
- return expandRectForTextMetrics(estimatePositionedPseudoRect(parentRect, pseudoStyles, width, height), metrics);
1614
- }
1615
-
1616
- if (parentStyles.display === 'flex' || parentStyles.display === 'inline-flex') {
1617
- return expandRectForTextMetrics(estimateFlexPseudoRect(parentRect, parentStyles, width, height, pseudoType), metrics);
1618
- }
1619
-
1620
- return expandRectForTextMetrics({
1621
- x: pseudoType === 'before' ? parentRect.x : parentRect.right - width,
1622
- y: parentRect.y + Math.max((parentRect.height - height) / 2, 0),
1623
- width,
1624
- height,
1625
- }, metrics);
1626
- }
1627
-
1628
- function measurePseudoTextMetrics(content, pseudoStyles) {
1629
- try {
1630
- const canvas = document.createElement('canvas');
1631
- const ctx = canvas.getContext('2d');
1632
- if (!ctx) {
1633
- return null;
1634
- }
1635
-
1636
- ctx.font = [
1637
- pseudoStyles.fontStyle,
1638
- pseudoStyles.fontVariant,
1639
- pseudoStyles.fontWeight,
1640
- pseudoStyles.fontSize,
1641
- pseudoStyles.fontFamily,
1642
- ].filter(Boolean).join(' ');
1643
-
1644
- const metrics = ctx.measureText(content);
1645
- const left = Number.isFinite(metrics.actualBoundingBoxLeft) ? metrics.actualBoundingBoxLeft : 0;
1646
- const right = Number.isFinite(metrics.actualBoundingBoxRight) ? metrics.actualBoundingBoxRight : metrics.width;
1647
- const ascent = Number.isFinite(metrics.actualBoundingBoxAscent) ? metrics.actualBoundingBoxAscent : 0;
1648
- const descent = Number.isFinite(metrics.actualBoundingBoxDescent) ? metrics.actualBoundingBoxDescent : 0;
1649
-
1650
- return {
1651
- width: Math.max(metrics.width || 0, Math.abs(left) + Math.abs(right)),
1652
- height: Math.max(parseCssPx(pseudoStyles.lineHeight), Math.abs(ascent) + Math.abs(descent)),
1653
- leftOverflow: Math.max(left, 0),
1654
- rightOverflow: Math.max(right - (metrics.width || 0), 0),
1655
- topOverflow: Math.max(ascent - parseCssPx(pseudoStyles.lineHeight), 0),
1656
- bottomOverflow: Math.max(descent, 0),
1657
- };
1658
- } catch (err) {
1659
- return null;
1660
- }
1661
- }
1662
-
1663
- function expandRectForTextMetrics(rect, metrics) {
1664
- if (!metrics) {
1665
- return rect;
1666
- }
1667
-
1668
- const leftOverflow = metrics.leftOverflow || 0;
1669
- const rightOverflow = metrics.rightOverflow || 0;
1670
- const topOverflow = metrics.topOverflow || 0;
1671
- const bottomOverflow = metrics.bottomOverflow || 0;
1672
-
1673
- return {
1674
- x: rect.x - leftOverflow,
1675
- y: rect.y - topOverflow,
1676
- width: Math.max(rect.width + leftOverflow + rightOverflow, metrics.width),
1677
- height: Math.max(rect.height + topOverflow + bottomOverflow, metrics.height),
1678
- };
1679
- }
1680
 
1681
  function applyPseudoTransformRect(rect, transformValue) {
1682
  const matrix = parseCssTransformMatrix(transformValue);
 
16
  */
17
  export async function extractFromFile(filePath, { width = 1440, height = 900 } = {}) {
18
  const absPath = resolve(filePath);
19
+ const browser = await chromium.launch({
20
+ args: ['--disable-web-security']
21
+ });
22
  const page = await browser.newPage({ viewport: { width, height } });
23
 
24
  await page.goto(pathToFileURL(absPath).href);
 
33
  * @returns {{ domTree, title: string }}
34
  */
35
  export async function extractFromHtml(html, { width = 1440, height = 900, baseUrl = null } = {}) {
36
+ const browser = await chromium.launch({
37
+ args: ['--disable-web-security']
38
+ });
39
  const page = await browser.newPage({ viewport: { width, height } });
40
  const htmlWithBase = injectBaseHref(html, normalizeBaseUrl(baseUrl));
41
 
 
57
  async function stabilizePage(page) {
58
  await page.waitForLoadState('networkidle');
59
 
60
+ // Wait for all images to finish loading completely (with a 2-second timeout)
61
+ await page.evaluate(async () => {
62
+ const imgs = Array.from(document.querySelectorAll('img'));
63
+ await Promise.all(imgs.map(img => {
64
+ if (img.complete) return Promise.resolve();
65
+ return new Promise(resolve => {
66
+ const timer = setTimeout(resolve, 2000);
67
+ img.onload = () => {
68
+ clearTimeout(timer);
69
+ resolve();
70
+ };
71
+ img.onerror = () => {
72
+ clearTimeout(timer);
73
+ resolve();
74
+ };
75
+ });
76
+ }));
77
+ });
78
+
79
  await page.evaluate(() => {
80
  document.querySelectorAll('.reveal').forEach(el => el.classList.add('visible'));
81
 
 
83
  const cs = window.getComputedStyle(el);
84
  return cs.animationName !== 'none' || cs.transitionDuration !== '0s';
85
  });
86
+ animated.forEach((el) => el.setAttribute('data-morphus-animated', '1'));
87
 
88
  const style = document.createElement('style');
89
  style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
90
  document.head.appendChild(style);
91
 
92
+ document.querySelectorAll('[data-morphus-animated="1"]').forEach((el) => {
93
+ const cs = window.getComputedStyle(el);
94
+ if (cs.opacity === '0' && shouldForceAnimatedElementVisible(el, cs)) {
95
+ el.style.opacity = '1';
96
+ if (isTranslateOnlyTransform(cs.transform)) {
97
+ el.style.transform = 'none';
98
+ }
99
+ }
100
+ });
101
+
102
+ limitPaginatedTableRows();
103
+
104
+ function shouldForceAnimatedElementVisible(el, cs) {
105
+ if (cs.display === 'none' || cs.visibility === 'hidden') {
106
+ return false;
107
+ }
108
+
109
+ if (cs.position === 'fixed') {
110
  return false;
111
  }
112
 
 
158
  && Math.abs(values[3] - 1) <= tolerance;
159
  }
160
  }
161
+
162
+ return /^translate(?:3d|x|y)?\(/i.test(text);
163
+ }
164
+
165
+ function limitPaginatedTableRows() {
166
+ const pagers = Array.from(document.querySelectorAll('*')).filter((el) => isPaginationElement(el));
167
+
168
+ for (const pager of pagers) {
169
+ const pagerRect = pager.getBoundingClientRect();
170
+ if (pagerRect.width <= 0 || pagerRect.height <= 0) {
171
+ continue;
172
+ }
173
+
174
+ const container = findPaginatedDataContainer(pager, pagerRect);
175
+ if (!container) {
176
+ continue;
177
+ }
178
+
179
+ const rows = getDataRows(container).filter((row) => !pager.contains(row));
180
+ if (rows.length < 8) {
181
+ continue;
182
+ }
183
+
184
+ const cutoffY = pagerRect.bottom - 1;
185
+ let hiddenCount = 0;
186
+ for (const row of rows) {
187
+ const rect = row.getBoundingClientRect();
188
+ if (rect.width <= 0 || rect.height <= 0) {
189
+ continue;
190
+ }
191
+ if (rect.top >= cutoffY) {
192
+ row.setAttribute('data-morphus-paginated-row-clipped', '1');
193
+ row.style.display = 'none';
194
+ hiddenCount++;
195
+ }
196
+ }
197
+
198
+ if (hiddenCount > 0) {
199
+ container.setAttribute('data-morphus-paginated-preview', '1');
200
+ }
201
+ }
202
+ }
203
+
204
+ function isPaginationElement(el) {
205
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) {
206
+ return false;
207
+ }
208
+
209
+ const identity = `${el.id || ''} ${String(el.className || '')} ${el.getAttribute('role') || ''}`.toLowerCase();
210
+ if (/(pagination|paginator|pager|page-nav|page-control)/.test(identity)) {
211
+ return true;
212
+ }
213
+
214
+ const text = normalizePaginationText(el.innerText || el.textContent || '');
215
+ if (!text || text.length > 160) {
216
+ return false;
217
+ }
218
+
219
+ return /\b(hal|page)\s*\d+\s*\/\s*\d+\b/i.test(text)
220
+ || /\b(baris|rows?)\s+\d+\s*[–-]\s*\d+\b/i.test(text);
221
+ }
222
+
223
+ function findPaginatedDataContainer(pager, pagerRect) {
224
+ let current = pager.parentElement;
225
+ while (current && current !== document.documentElement) {
226
+ const rows = getDataRows(current).filter((row) => !pager.contains(row));
227
+ if (rows.length >= 8) {
228
+ const before = rows.filter((row) => {
229
+ const rect = row.getBoundingClientRect();
230
+ return rect.width > 0 && rect.height > 0 && rect.bottom <= pagerRect.top + 1;
231
+ });
232
+ const after = rows.filter((row) => {
233
+ const rect = row.getBoundingClientRect();
234
+ return rect.width > 0 && rect.height > 0 && rect.top >= pagerRect.bottom - 1;
235
+ });
236
+
237
+ if (before.length >= 3 && after.length >= 1) {
238
+ return current;
239
+ }
240
+ }
241
+
242
+ current = current.parentElement;
243
+ }
244
+
245
+ return null;
246
+ }
247
+
248
+ function getDataRows(container) {
249
+ const seen = new Set();
250
+ const rows = [];
251
+ const selectors = ['tr', '[role="row"]'];
252
+
253
+ for (const selector of selectors) {
254
+ for (const row of container.querySelectorAll(selector)) {
255
+ if (seen.has(row) || row.closest('thead')) {
256
+ continue;
257
+ }
258
+ seen.add(row);
259
+ rows.push(row);
260
+ }
261
+ }
262
+
263
+ return rows;
264
+ }
265
+
266
+ function normalizePaginationText(value) {
267
+ return String(value || '').replace(/\s+/g, ' ').trim();
268
+ }
269
+ });
270
 
271
  await waitForCanvasPaint(page);
272
  }
 
296
  }
297
 
298
  const now = performance.now();
299
+ const state = window.__morphusCanvasCaptureState || {
300
  startedAt: now,
301
  lastSignature: '',
302
  stableCount: 0,
 
316
  state.stableCount = 0;
317
  }
318
 
319
+ window.__morphusCanvasCaptureState = state;
320
  return state.stableCount >= 2 && now - state.startedAt >= 500;
321
 
322
  function captureCanvasSnapshot(canvas) {
 
333
  return { readable: false };
334
  }
335
 
336
+ canvas.__morphusCanvasCaptureSrc = src;
337
  return {
338
  readable: Boolean(src && src !== 'data:,'),
339
  signature: `${width}x${height}:${src.length}:${hashString(src)}`,
 
400
  */
401
  function walkDOMInBrowser() {
402
  const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'HEAD', 'NOSCRIPT']);
403
+ const TEXT_TAGS = new Set(['p', 'span', 'a', 'label', 'em', 'strong', 'b', 'i', 'small', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'th']);
404
+ const INLINE_TAGS = new Set([
405
+ 'a',
406
+ 'abbr',
407
+ 'b',
408
+ 'bdi',
409
+ 'bdo',
410
+ 'cite',
411
+ 'code',
412
+ 'data',
413
+ 'dfn',
414
+ 'em',
415
+ 'i',
416
+ 'kbd',
417
+ 'label',
418
+ 'mark',
419
+ 'q',
420
+ 's',
421
+ 'samp',
422
+ 'small',
423
+ 'span',
424
+ 'strong',
425
+ 'sub',
426
+ 'sup',
427
+ 'time',
428
+ 'u',
429
+ 'var',
430
+ 'br',
431
+ 'wbr',
432
+ ]);
433
  const TEXT_INPUT_TYPES = new Set([
434
  '',
435
  'date',
 
463
  if (isVisuallyHiddenElement(cs)) return null;
464
 
465
  const rawText = normalizeTextContent(el.innerText || el.textContent || '');
466
+ const hasVisualBox =
467
+ !isTransparentColor(cs.backgroundColor) ||
468
+ cs.backgroundImage !== 'none' ||
469
+ cs.borderStyle !== 'none' ||
470
  parseFloat(cs.borderTopWidth) > 0 ||
471
  parseFloat(cs.borderRightWidth) > 0 ||
472
  parseFloat(cs.borderBottomWidth) > 0 ||
 
474
  parseFloat(cs.paddingTop) > 0 ||
475
  parseFloat(cs.paddingRight) > 0 ||
476
  parseFloat(cs.paddingBottom) > 0 ||
477
+ parseFloat(cs.paddingLeft) > 0 ||
478
+ cs.boxShadow !== 'none' ||
479
+ cs.filter !== 'none';
480
+
481
+ const inlineVisualFragments = extractInlineVisualFragments(el, tag, cs, csBefore, csAfter, rect, rawText, hasVisualBox);
482
+ if (inlineVisualFragments) {
483
+ return inlineVisualFragments;
484
+ }
485
+
486
+ const inlineTextFragments = extractInlineTextFragments(el, tag, cs, csBefore, csAfter, rect, rawText, hasVisualBox);
487
+ if (inlineTextFragments) {
488
+ return inlineTextFragments;
489
+ }
490
+
491
+ const hasOnlyInlineTextChildren = Boolean(rawText) && Array.from(el.children).length > 0 && Array.from(el.children).every((child) => isInlineTextChild(child));
492
+ const isTextContainer = Boolean(rawText)
493
+ && !hasVisualBox
494
+ && !hasRenderablePseudo(csBefore)
495
+ && !hasRenderablePseudo(csAfter)
496
  && canCollapseToTextContainer(el, tag, cs, hasOnlyInlineTextChildren);
497
 
498
+ const beforeData = extractPseudoElementData(el, tag, cs, csBefore, 'before');
499
+ const afterData = extractPseudoElementData(el, tag, cs, csAfter, 'after');
500
+ const formControl = extractFormControlData(el, tag, cs);
501
+ const nodeText = formControl ? null : rawText;
502
+ const textData = nodeText ? extractTextData(el) : null;
503
+ const renderedText = textData?.text || nodeText;
504
+ const svgMarkup = isSvg ? serializeSvgElement(el, rect) : null;
505
+ const imageData = isImage
506
+ ? extractImageData(el)
507
+ : isCanvas
508
+ ? extractCanvasImageData(el)
509
+ : null;
510
+
511
+ const children = isSvg || isImage || isCanvas || isTextContainer || formControl
512
+ ? []
513
+ : Array.from(el.childNodes)
514
+ .map((child) => getChildNode(child, el, cs, depth + 1))
515
+ .filter(Boolean);
516
 
517
  return {
518
+ tag,
519
+ id: el.id || null,
520
+ classList: Array.from(el.classList),
521
+ text: renderedText || null,
522
+ textRuns: textData?.runs || [],
523
  isTextContainer,
524
+ rect: {
525
+ x: rect.x,
526
+ y: rect.y,
527
+ width: rect.width,
528
+ height: rect.height,
529
+ offsetWidth: el.offsetWidth !== undefined ? el.offsetWidth : rect.width,
530
+ offsetHeight: el.offsetHeight !== undefined ? el.offsetHeight : rect.height,
531
+ },
532
  computed: extractRelevantStyles(cs),
533
  ...(formControl ? { formControl } : {}),
534
  ...(svgMarkup ? { svgMarkup } : {}),
 
537
  before: beforeData,
538
  after: afterData,
539
  },
540
+ children,
541
+ };
542
+ }
543
+
544
+ function extractInlineVisualFragments(el, tag, cs, csBefore, csAfter, rect, rawText, hasVisualBox) {
545
+ if (!shouldSplitInlineVisualElement(el, tag, cs, csBefore, csAfter, rawText, hasVisualBox)) {
546
+ return null;
547
+ }
548
+
549
+ const fragmentRects = getElementClientRects(el);
550
+ if (fragmentRects.length <= 1) {
551
+ return null;
552
+ }
553
+
554
+ const lines = collectInlineVisualTextLines(el, fragmentRects);
555
+ if (!lines.some((line) => line.text)) {
556
+ return null;
557
+ }
558
+
559
+ const wrapperComputed = makeTransparentInlineWrapperStyles(cs, rect);
560
+ const fragmentNodes = fragmentRects
561
+ .map((fragmentRect, index) => buildInlineVisualFragmentNode(el, tag, cs, fragmentRect, lines[index]))
562
+ .filter(Boolean);
563
+
564
+ if (fragmentNodes.length <= 1) {
565
+ return null;
566
+ }
567
+
568
+ return {
569
+ tag,
570
+ id: el.id || null,
571
+ classList: Array.from(el.classList),
572
+ text: null,
573
+ textRuns: [],
574
+ isTextContainer: false,
575
+ _inlineFragmentGroup: true,
576
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
577
+ computed: wrapperComputed,
578
+ pseudo: {
579
+ before: null,
580
+ after: null,
581
+ },
582
+ children: fragmentNodes,
583
+ };
584
+ }
585
+
586
+ function extractInlineTextFragments(el, tag, cs, csBefore, csAfter, rect, rawText, hasVisualBox) {
587
+ if (!shouldSplitInlineTextElement(el, tag, cs, csBefore, csAfter, rawText, hasVisualBox)) {
588
+ return null;
589
+ }
590
+
591
+ const fragmentRects = getElementClientRects(el);
592
+ if (fragmentRects.length <= 1) {
593
+ return null;
594
+ }
595
+
596
+ const lines = collectInlineVisualTextLines(el, fragmentRects);
597
+ const fragmentNodes = lines
598
+ .map((line) => buildInlineTextFragmentNode(el, cs, line))
599
+ .filter(Boolean);
600
+
601
+ if (fragmentNodes.length <= 1) {
602
+ return null;
603
+ }
604
+
605
+ return {
606
+ tag,
607
+ id: el.id || null,
608
+ classList: Array.from(el.classList),
609
+ text: null,
610
+ textRuns: [],
611
+ isTextContainer: false,
612
+ _inlineTextFragmentGroup: true,
613
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
614
+ computed: makeTransparentInlineWrapperStyles(cs, rect),
615
+ pseudo: {
616
+ before: null,
617
+ after: null,
618
+ },
619
+ children: fragmentNodes,
620
+ };
621
+ }
622
+
623
+ function shouldSplitInlineTextElement(el, tag, cs, csBefore, csAfter, rawText, hasVisualBox) {
624
+ if (hasVisualBox || !rawText || !INLINE_TAGS.has(tag)) {
625
+ return false;
626
+ }
627
+
628
+ if (cs.display !== 'inline' || cs.position !== 'static') {
629
+ return false;
630
+ }
631
+
632
+ if (hasRenderablePseudo(csBefore) || hasRenderablePseudo(csAfter)) {
633
+ return false;
634
+ }
635
+
636
+ return getElementClientRects(el).length > 1;
637
+ }
638
+
639
+ function buildInlineTextFragmentNode(el, cs, line) {
640
+ if (!line?.text || !line.textRect) {
641
+ return null;
642
+ }
643
+
644
+ const computed = extractRelevantStyles(cs);
645
+ computed.display = 'inline';
646
+ computed.position = 'static';
647
+ computed.width = `${line.textRect.width}px`;
648
+ computed.height = `${line.textRect.height}px`;
649
+ computed.minWidth = '0px';
650
+ computed.minHeight = '0px';
651
+
652
+ return {
653
+ tag: el.tagName.toLowerCase(),
654
+ id: null,
655
+ classList: Array.from(el.classList),
656
+ text: line.text,
657
+ textRuns: line.runs,
658
+ isTextContainer: true,
659
+ _inlineTextFragment: true,
660
+ rect: line.textRect,
661
+ computed,
662
+ pseudo: {
663
+ before: null,
664
+ after: null,
665
+ },
666
+ children: [],
667
+ };
668
+ }
669
+
670
+ function shouldSplitInlineVisualElement(el, tag, cs, csBefore, csAfter, rawText, hasVisualBox) {
671
+ if (!hasVisualBox || !rawText || !INLINE_TAGS.has(tag)) {
672
+ return false;
673
+ }
674
+
675
+ if (cs.display !== 'inline' || cs.position !== 'static') {
676
+ return false;
677
+ }
678
+
679
+ if (hasRenderablePseudo(csBefore) || hasRenderablePseudo(csAfter)) {
680
+ return false;
681
+ }
682
+
683
+ return getElementClientRects(el).length > 1;
684
+ }
685
+
686
+ function getElementClientRects(el) {
687
+ return Array.from(el.getClientRects())
688
+ .map(rectToPlainObject)
689
+ .filter((rect) => rect.width > 0 && rect.height > 0);
690
+ }
691
+
692
+ function buildInlineVisualFragmentNode(el, tag, cs, fragmentRect, line) {
693
+ const computed = extractRelevantStyles(cs);
694
+ computed.display = 'inline';
695
+ computed.position = 'static';
696
+ computed.width = `${fragmentRect.width}px`;
697
+ computed.height = `${fragmentRect.height}px`;
698
+ computed.minWidth = '0px';
699
+ computed.minHeight = '0px';
700
+
701
+ const textNode = line?.text
702
+ ? buildInlineFragmentTextNode(cs, line)
703
+ : null;
704
+
705
+ return {
706
+ tag,
707
+ id: null,
708
+ classList: Array.from(el.classList),
709
+ text: null,
710
+ textRuns: [],
711
+ isTextContainer: false,
712
+ _inlineFragment: true,
713
+ rect: fragmentRect,
714
+ computed,
715
+ pseudo: {
716
+ before: null,
717
+ after: null,
718
+ },
719
+ children: textNode ? [textNode] : [],
720
+ };
721
+ }
722
+
723
+ function buildInlineFragmentTextNode(cs, line) {
724
+ const computed = extractRelevantStyles(cs);
725
+ computed.display = 'inline';
726
+ computed.position = 'static';
727
+ computed.width = `${line.textRect.width}px`;
728
+ computed.height = `${line.textRect.height}px`;
729
+ computed.minWidth = '0px';
730
+ computed.minHeight = '0px';
731
+ computed.backgroundColor = 'rgba(0, 0, 0, 0)';
732
+ computed.backgroundImage = 'none';
733
+ computed.paddingTop = '0px';
734
+ computed.paddingRight = '0px';
735
+ computed.paddingBottom = '0px';
736
+ computed.paddingLeft = '0px';
737
+ computed.border = '0px none rgba(0, 0, 0, 0)';
738
+ computed.borderWidth = '0px';
739
+ computed.borderStyle = 'none';
740
+ computed.boxShadow = 'none';
741
+
742
+ return {
743
+ tag: 'span',
744
+ id: null,
745
+ classList: [],
746
+ text: line.text,
747
+ textRuns: line.runs,
748
+ isTextContainer: true,
749
+ _inlineFragmentText: true,
750
+ rect: line.textRect,
751
+ computed,
752
+ pseudo: {
753
+ before: null,
754
+ after: null,
755
+ },
756
+ children: [],
757
+ };
758
+ }
759
+
760
+ function collectInlineVisualTextLines(el, fragmentRects) {
761
+ const lines = fragmentRects.map((fragmentRect) => ({
762
+ fragmentRect,
763
+ text: '',
764
+ runs: [],
765
+ textRect: null,
766
+ }));
767
+ let pendingWhitespace = false;
768
+ let previousLineIndex = null;
769
+
770
+ function walkInlineText(node, styleEl) {
771
+ if (node.nodeType === Node.TEXT_NODE) {
772
+ collectTextNodeLines(node, styleEl);
773
+ return;
774
+ }
775
+
776
+ if (node.nodeType !== Node.ELEMENT_NODE) {
777
+ return;
778
+ }
779
+
780
+ const element = node;
781
+ if (element.tagName.toLowerCase() === 'br') {
782
+ pendingWhitespace = false;
783
+ previousLineIndex = null;
784
+ return;
785
+ }
786
+
787
+ for (const child of element.childNodes) {
788
+ walkInlineText(child, element);
789
+ }
790
+ }
791
+
792
+ function collectTextNodeLines(textNode, styleEl) {
793
+ const text = textNode.textContent || '';
794
+ const tokenPattern = /\s+|\S+/g;
795
+ let match;
796
+
797
+ while ((match = tokenPattern.exec(text)) !== null) {
798
+ const token = match[0];
799
+ if (/^\s+$/.test(token)) {
800
+ pendingWhitespace = true;
801
+ continue;
802
+ }
803
+
804
+ appendMeasuredToken(textNode, match.index, match.index + token.length, token, styleEl);
805
+ }
806
+ }
807
+
808
+ function appendMeasuredToken(textNode, start, end, token, styleEl) {
809
+ const tokenRects = measureTextRangeClientRects(textNode, start, end);
810
+ if (tokenRects.length <= 1) {
811
+ const rect = tokenRects[0];
812
+ if (!rect) return;
813
+ appendLineText(findBestFragmentIndex(rect, fragmentRects), token, rect, styleEl);
814
+ return;
815
+ }
816
+
817
+ let segment = '';
818
+ let segmentLineIndex = null;
819
+ let segmentRect = null;
820
+
821
+ for (let offset = start; offset < end; offset++) {
822
+ const char = (textNode.textContent || '').slice(offset, offset + 1);
823
+ const charRect = measureTextRangeClientRects(textNode, offset, offset + 1)[0];
824
+ if (!charRect) continue;
825
+
826
+ const lineIndex = findBestFragmentIndex(charRect, fragmentRects);
827
+ if (segment && lineIndex !== segmentLineIndex) {
828
+ appendLineText(segmentLineIndex, segment, segmentRect, styleEl);
829
+ segment = '';
830
+ segmentRect = null;
831
+ }
832
+
833
+ segment += char;
834
+ segmentLineIndex = lineIndex;
835
+ segmentRect = unionTwoRects(segmentRect, charRect);
836
+ }
837
+
838
+ if (segment) {
839
+ appendLineText(segmentLineIndex, segment, segmentRect, styleEl);
840
+ }
841
+ }
842
+
843
+ function appendLineText(lineIndex, rawToken, rect, styleEl) {
844
+ const line = lines[lineIndex];
845
+ if (!line || !rawToken || !rect) {
846
+ return;
847
+ }
848
+
849
+ const normalizedToken = normalizeTextFragment(rawToken);
850
+ if (!normalizedToken) {
851
+ return;
852
+ }
853
+
854
+ const prefix = pendingWhitespace && previousLineIndex === lineIndex && line.text ? ' ' : '';
855
+ const text = prefix + normalizedToken;
856
+ line.text += text;
857
+ line.runs.push({
858
+ text,
859
+ lineIndex: 0,
860
+ computed: extractTextRunStyles(window.getComputedStyle(styleEl || el)),
861
+ });
862
+ line.textRect = unionTwoRects(line.textRect, rect);
863
+ pendingWhitespace = false;
864
+ previousLineIndex = lineIndex;
865
+ }
866
+
867
+ for (const child of el.childNodes) {
868
+ walkInlineText(child, el);
869
+ }
870
+
871
+ for (const line of lines) {
872
+ if (!line.textRect) {
873
+ line.textRect = line.fragmentRect;
874
+ }
875
+ line.text = normalizeTextContent(line.text);
876
+ }
877
+
878
+ return lines;
879
+ }
880
+
881
+ function measureTextRangeClientRects(textNode, start, end) {
882
+ const range = document.createRange();
883
+ range.setStart(textNode, start);
884
+ range.setEnd(textNode, end);
885
+ return Array.from(range.getClientRects())
886
+ .map(rectToPlainObject)
887
+ .filter((rect) => rect.width > 0 && rect.height > 0);
888
+ }
889
+
890
+ function findBestFragmentIndex(rect, fragmentRects) {
891
+ let bestIndex = 0;
892
+ let bestScore = -1;
893
+ const centerY = rect.y + rect.height / 2;
894
+
895
+ for (let index = 0; index < fragmentRects.length; index++) {
896
+ const fragment = fragmentRects[index];
897
+ const overlapX = Math.max(0, Math.min(rect.x + rect.width, fragment.x + fragment.width) - Math.max(rect.x, fragment.x));
898
+ const overlapY = Math.max(0, Math.min(rect.y + rect.height, fragment.y + fragment.height) - Math.max(rect.y, fragment.y));
899
+ const area = overlapX * overlapY;
900
+ const yDistance = Math.abs(centerY - (fragment.y + fragment.height / 2));
901
+ const score = area > 0 ? area : -yDistance;
902
+ if (score > bestScore) {
903
+ bestScore = score;
904
+ bestIndex = index;
905
+ }
906
+ }
907
+
908
+ return bestIndex;
909
+ }
910
+
911
+ function makeTransparentInlineWrapperStyles(cs, rect) {
912
+ const computed = extractRelevantStyles(cs);
913
+ computed.display = 'inline';
914
+ computed.position = 'static';
915
+ computed.width = `${rect.width}px`;
916
+ computed.height = `${rect.height}px`;
917
+ computed.minWidth = '0px';
918
+ computed.minHeight = '0px';
919
+ computed.paddingTop = '0px';
920
+ computed.paddingRight = '0px';
921
+ computed.paddingBottom = '0px';
922
+ computed.paddingLeft = '0px';
923
+ computed.backgroundColor = 'rgba(0, 0, 0, 0)';
924
+ computed.backgroundImage = 'none';
925
+ computed.borderRadius = '0px';
926
+ computed.borderTopLeftRadius = '0px';
927
+ computed.borderTopRightRadius = '0px';
928
+ computed.borderBottomRightRadius = '0px';
929
+ computed.borderBottomLeftRadius = '0px';
930
+ computed.border = '0px none rgba(0, 0, 0, 0)';
931
+ computed.borderWidth = '0px';
932
+ computed.borderColor = 'rgba(0, 0, 0, 0)';
933
+ computed.borderStyle = 'none';
934
+ computed.borderTopWidth = '0px';
935
+ computed.borderRightWidth = '0px';
936
+ computed.borderBottomWidth = '0px';
937
+ computed.borderLeftWidth = '0px';
938
+ computed.borderTopColor = 'rgba(0, 0, 0, 0)';
939
+ computed.borderRightColor = 'rgba(0, 0, 0, 0)';
940
+ computed.borderBottomColor = 'rgba(0, 0, 0, 0)';
941
+ computed.borderLeftColor = 'rgba(0, 0, 0, 0)';
942
+ computed.borderTopStyle = 'none';
943
+ computed.borderRightStyle = 'none';
944
+ computed.borderBottomStyle = 'none';
945
+ computed.borderLeftStyle = 'none';
946
+ computed.boxShadow = 'none';
947
+ return computed;
948
+ }
949
+
950
+ function rectToPlainObject(rect) {
951
+ return {
952
+ x: rect.x,
953
+ y: rect.y,
954
+ width: rect.width,
955
+ height: rect.height,
956
+ };
957
+ }
958
+
959
+ function unionTwoRects(a, b) {
960
+ if (!a) return b ? { x: b.x, y: b.y, width: b.width, height: b.height } : null;
961
+ if (!b) return { x: a.x, y: a.y, width: a.width, height: a.height };
962
+
963
+ const left = Math.min(a.x, b.x);
964
+ const top = Math.min(a.y, b.y);
965
+ const right = Math.max(a.x + a.width, b.x + b.width);
966
+ const bottom = Math.max(a.y + a.height, b.y + b.height);
967
+ return {
968
+ x: left,
969
+ y: top,
970
+ width: Math.max(right - left, 0),
971
+ height: Math.max(bottom - top, 0),
972
+ };
973
+ }
974
+
975
+ function extractFormControlData(el, tag, computedStyles) {
976
+ if (tag === 'select') {
977
+ return extractSelectControlData(el);
978
+ }
979
+
980
+ if (tag !== 'input' && tag !== 'textarea') {
981
+ return null;
982
+ }
983
 
984
  const type = tag === 'input'
985
  ? String(el.getAttribute('type') || 'text').trim().toLowerCase()
 
1006
  type,
1007
  value,
1008
  placeholder,
1009
+ ...(placeholderComputed ? { placeholderComputed } : {}),
1010
+ };
1011
+ }
1012
+
1013
+ function extractSelectControlData(el) {
1014
+ const selectedOptions = Array.from(el.selectedOptions || []);
1015
+ const renderedOptions = selectedOptions.length > 0
1016
+ ? selectedOptions
1017
+ : [el.options && el.selectedIndex >= 0 ? el.options[el.selectedIndex] : null].filter(Boolean);
1018
+ const renderedText = normalizeFormControlText(
1019
+ renderedOptions
1020
+ .map((option) => option.label || option.textContent || '')
1021
+ .filter(Boolean)
1022
+ .join(el.multiple ? '\n' : ' ')
1023
+ );
1024
+
1025
+ if (!renderedText) {
1026
+ return null;
1027
+ }
1028
+
1029
+ const size = Number.parseInt(el.getAttribute('size') || '1', 10);
1030
+ return {
1031
+ type: 'select',
1032
+ value: renderedText,
1033
+ optionValue: renderedOptions[0] ? renderedOptions[0].value : el.value,
1034
+ hasChevron: !el.multiple && (!Number.isFinite(size) || size <= 1),
1035
+ };
1036
+ }
1037
 
1038
  function extractPlaceholderStyles(el, fallbackStyles) {
1039
  try {
 
1112
  borderBottomStyle: cs.borderBottomStyle,
1113
  borderLeftStyle: cs.borderLeftStyle,
1114
  boxShadow: cs.boxShadow,
1115
+ filter: cs.filter,
1116
  overflow: cs.overflow,
1117
  overflowX: cs.overflowX,
1118
  overflowY: cs.overflowY,
 
1127
  lineHeight: cs.lineHeight,
1128
  letterSpacing: cs.letterSpacing,
1129
  textAlign: cs.textAlign,
1130
+ textTransform: cs.textTransform,
1131
+ whiteSpace: cs.whiteSpace,
1132
+ textOverflow: cs.textOverflow,
1133
+ textShadow: cs.textShadow,
1134
+ textDecoration: cs.textDecoration,
1135
+ textDecorationLine: cs.textDecorationLine,
1136
+ textDecorationStyle: cs.textDecorationStyle,
1137
+ textDecorationColor: cs.textDecorationColor,
1138
+ textDecorationThickness: cs.textDecorationThickness,
1139
+ webkitTextStrokeWidth: cs.webkitTextStrokeWidth,
1140
+ webkitTextStrokeColor: cs.webkitTextStrokeColor,
1141
+ writingMode: cs.writingMode,
1142
  // Positioning
1143
  top: cs.top,
1144
  right: cs.right,
 
1167
  return null;
1168
  }
1169
 
1170
+ let base64Src = src;
1171
+ try {
1172
+ if (el.complete && el.naturalWidth > 0 && el.naturalHeight > 0) {
1173
+ const canvas = document.createElement('canvas');
1174
+ canvas.width = el.naturalWidth;
1175
+ canvas.height = el.naturalHeight;
1176
+ const ctx = canvas.getContext('2d');
1177
+ ctx.drawImage(el, 0, 0);
1178
+ base64Src = canvas.toDataURL('image/png');
1179
+ }
1180
+ } catch (err) {
1181
+ // Fallback to original src if canvas conversion fails
1182
+ }
1183
+
1184
  return {
1185
+ src: base64Src,
1186
  alt: el.getAttribute('alt') || '',
1187
  naturalWidth: Number.isFinite(el.naturalWidth) ? el.naturalWidth : 0,
1188
  naturalHeight: Number.isFinite(el.naturalHeight) ? el.naturalHeight : 0,
 
1196
  return null;
1197
  }
1198
 
1199
+ let src = String(el.__morphusCanvasCaptureSrc || '');
1200
  try {
1201
  src = src || el.toDataURL('image/png');
1202
  } catch (err) {
 
1220
  const pieces = [];
1221
  let lineIndex = 0;
1222
 
1223
+ function pushText(text, styleSource) {
1224
+ const normalized = normalizeTextFragment(text);
1225
+ if (!normalized) return;
1226
+ pieces.push(normalized);
1227
+ runs.push({
1228
+ text: normalized,
1229
+ lineIndex,
1230
+ computed: extractTextRunStyles(resolveTextRunComputedStyles(styleSource)),
1231
+ });
1232
+ }
1233
+
1234
+ function pushPseudoText(element, pseudoType) {
1235
+ const pseudoStyles = window.getComputedStyle(element, pseudoType);
1236
+ const content = parseCssContent(pseudoStyles.content);
1237
+ if (!content) return;
1238
+ pushText(content, pseudoStyles);
1239
+ }
1240
+
1241
+ function walkText(node, styleEl) {
1242
+ if (node.nodeType === Node.TEXT_NODE) {
1243
+ pushText(node.textContent || '', styleEl);
1244
+ return;
1245
  }
1246
 
1247
  if (node.nodeType !== Node.ELEMENT_NODE) return;
 
1252
  if (tagName === 'br') {
1253
  pieces.push('\n');
1254
  lineIndex++;
1255
+ return;
1256
+ }
1257
+
1258
+ const nextStyleEl = element;
1259
+ pushPseudoText(element, '::before');
1260
+ for (const child of element.childNodes) {
1261
+ walkText(child, nextStyleEl);
1262
+ }
1263
+ pushPseudoText(element, '::after');
1264
+ }
1265
 
1266
  for (const child of el.childNodes) {
1267
  walkText(child, el);
 
1272
  .replace(/[ \t]*\n[ \t]*/g, '\n')
1273
  .replace(/[ \t]{2,}/g, ' ')
1274
  .trim();
1275
+
1276
+ return { text, runs };
1277
+ }
1278
+
1279
+ function resolveTextRunComputedStyles(styleSource) {
1280
+ if (styleSource && typeof styleSource.getPropertyValue === 'function') {
1281
+ return styleSource;
1282
+ }
1283
+
1284
+ return window.getComputedStyle(styleSource);
1285
+ }
1286
 
1287
  function extractTextRunStyles(cs) {
1288
  return {
 
1296
  letterSpacing: cs.letterSpacing,
1297
  textAlign: cs.textAlign,
1298
  textTransform: cs.textTransform,
1299
+ color: cs.color,
1300
+ opacity: cs.opacity,
1301
+ textShadow: cs.textShadow,
1302
+ textDecoration: cs.textDecoration,
1303
+ textDecorationLine: cs.textDecorationLine,
1304
+ textDecorationStyle: cs.textDecorationStyle,
1305
+ textDecorationColor: cs.textDecorationColor,
1306
+ textDecorationThickness: cs.textDecorationThickness,
1307
+ webkitTextStrokeWidth: cs.webkitTextStrokeWidth,
1308
+ webkitTextStrokeColor: cs.webkitTextStrokeColor,
1309
+ writingMode: cs.writingMode,
1310
+ filter: cs.filter,
1311
  };
1312
  }
1313
 
 
1336
  const cloneEl = cloneElements[index];
1337
  if (!sourceEl || !cloneEl) continue;
1338
 
1339
+ cloneEl.removeAttribute('data-morphus-animated');
1340
  const cs = window.getComputedStyle(sourceEl);
1341
  const isRoot = index === 0;
1342
 
 
1388
  return String(Math.max(Math.round(number * 1000) / 1000, 1));
1389
  }
1390
 
1391
+ function getChildNode(child, parentEl, parentStyles, depth) {
1392
+ if (child.nodeType === Node.TEXT_NODE) {
1393
+ return getDirectTextNode(child, parentEl, parentStyles);
1394
+ }
1395
+
1396
+ if (child.nodeType === Node.ELEMENT_NODE) {
1397
+ const node = getNode(child, depth);
1398
+ if (!node) {
1399
+ return null;
1400
+ }
1401
+
1402
+ if (parentEl && parentStyles && clippingEnabled(parentStyles)) {
1403
+ const parentRect = parentEl.getBoundingClientRect();
1404
+ if (isClippedOutsideParent(node.rect, parentRect, parentStyles)) {
1405
+ return null;
1406
+ }
1407
+ }
1408
+
1409
+ return node;
1410
+ }
1411
+
1412
+ return null;
1413
+ }
1414
+
1415
+ function getDirectTextNode(textNode, parentEl, parentStyles) {
1416
+ const normalizedText = normalizeTextFragment(textNode.textContent || '').trim();
1417
+ if (!normalizedText) {
1418
+ return null;
1419
+ }
1420
 
1421
  const range = document.createRange();
1422
  range.selectNodeContents(textNode);
1423
+ const rect = range.getBoundingClientRect();
1424
+ if (rect.width === 0 && rect.height === 0) {
1425
+ return null;
1426
+ }
1427
+
1428
+ if (parentEl && parentStyles && clippingEnabled(parentStyles)) {
1429
+ const parentRect = parentEl.getBoundingClientRect();
1430
+ if (isClippedOutsideParent(rect, parentRect, parentStyles)) {
1431
+ return null;
1432
+ }
1433
+ }
1434
+
1435
+ const computed = extractRelevantStyles(parentStyles);
1436
  computed.display = 'inline';
1437
  computed.position = 'static';
1438
  computed.width = `${rect.width}px`;
 
1602
  }
1603
 
1604
  const parentRect = el.getBoundingClientRect();
1605
+ const rect = estimatePseudoTextRect(parentRect, parentStyles, pseudoStyles, pseudoType, content);
1606
  const transformedRect = applyPseudoTransformRect(rect, pseudoStyles.transform);
1607
  const finalRect = transformedRect || rect;
1608
 
 
1648
  parseFloat(cs.borderRightWidth) > 0 ||
1649
  parseFloat(cs.borderBottomWidth) > 0 ||
1650
  parseFloat(cs.borderLeftWidth) > 0 ||
1651
+ cs.boxShadow !== 'none' ||
1652
+ cs.filter !== 'none';
1653
+ }
1654
+
1655
+ function estimatePseudoTextRect(parentRect, parentStyles, pseudoStyles, pseudoType, content = '') {
1656
+ const metrics = content ? measurePseudoTextMetrics(content, pseudoStyles) : null;
1657
+ const width = Math.max(parseCssPx(pseudoStyles.width), metrics?.width || 0);
1658
+ const height = Math.max(
1659
+ parseCssPx(pseudoStyles.height) || parseCssPx(pseudoStyles.lineHeight) || parseCssPx(pseudoStyles.fontSize),
1660
+ metrics?.height || 0
1661
+ );
1662
+ const position = pseudoStyles.position;
1663
+
1664
+ if (position === 'absolute' || position === 'fixed') {
1665
+ return expandRectForTextMetrics(estimatePositionedPseudoRect(parentRect, pseudoStyles, width, height), metrics);
1666
+ }
1667
+
1668
+ if (parentStyles.display === 'flex' || parentStyles.display === 'inline-flex') {
1669
+ return expandRectForTextMetrics(estimateFlexPseudoRect(parentRect, parentStyles, width, height, pseudoType), metrics);
1670
+ }
1671
+
1672
+ return expandRectForTextMetrics({
1673
+ x: pseudoType === 'before' ? parentRect.x : parentRect.right - width,
1674
+ y: parentRect.y + Math.max((parentRect.height - height) / 2, 0),
1675
+ width,
1676
+ height,
1677
+ }, metrics);
1678
  }
1679
 
1680
+ function measurePseudoTextMetrics(content, pseudoStyles) {
1681
+ try {
1682
+ const canvas = document.createElement('canvas');
1683
+ const ctx = canvas.getContext('2d');
1684
+ if (!ctx) {
1685
+ return null;
1686
+ }
1687
+
1688
+ ctx.font = [
1689
+ pseudoStyles.fontStyle,
1690
+ pseudoStyles.fontVariant,
1691
+ pseudoStyles.fontWeight,
1692
+ pseudoStyles.fontSize,
1693
+ pseudoStyles.fontFamily,
1694
+ ].filter(Boolean).join(' ');
1695
+
1696
+ const metrics = ctx.measureText(content);
1697
+ const left = Number.isFinite(metrics.actualBoundingBoxLeft) ? metrics.actualBoundingBoxLeft : 0;
1698
+ const right = Number.isFinite(metrics.actualBoundingBoxRight) ? metrics.actualBoundingBoxRight : metrics.width;
1699
+ const ascent = Number.isFinite(metrics.actualBoundingBoxAscent) ? metrics.actualBoundingBoxAscent : 0;
1700
+ const descent = Number.isFinite(metrics.actualBoundingBoxDescent) ? metrics.actualBoundingBoxDescent : 0;
1701
+
1702
+ return {
1703
+ width: Math.max(metrics.width || 0, Math.abs(left) + Math.abs(right)),
1704
+ height: Math.max(parseCssPx(pseudoStyles.lineHeight), Math.abs(ascent) + Math.abs(descent)),
1705
+ leftOverflow: Math.max(left, 0),
1706
+ rightOverflow: Math.max(right - (metrics.width || 0), 0),
1707
+ topOverflow: Math.max(ascent - parseCssPx(pseudoStyles.lineHeight), 0),
1708
+ bottomOverflow: Math.max(descent, 0),
1709
+ };
1710
+ } catch (err) {
1711
+ return null;
1712
+ }
1713
+ }
1714
+
1715
+ function expandRectForTextMetrics(rect, metrics) {
1716
+ if (!metrics) {
1717
+ return rect;
1718
+ }
1719
+
1720
+ const leftOverflow = metrics.leftOverflow || 0;
1721
+ const rightOverflow = metrics.rightOverflow || 0;
1722
+ const topOverflow = metrics.topOverflow || 0;
1723
+ const bottomOverflow = metrics.bottomOverflow || 0;
1724
+
1725
+ return {
1726
+ x: rect.x - leftOverflow,
1727
+ y: rect.y - topOverflow,
1728
+ width: Math.max(rect.width + leftOverflow + rightOverflow, metrics.width),
1729
+ height: Math.max(rect.height + topOverflow + bottomOverflow, metrics.height),
1730
+ };
1731
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1732
 
1733
  function applyPseudoTransformRect(rect, transformValue) {
1734
  const matrix = parseCssTransformMatrix(transformValue);
src/figma/css-to-figma.js CHANGED
@@ -23,80 +23,80 @@ 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
- return isClippingOverflowValue(computed[prop]);
41
- });
42
- }
43
-
44
- function isClippingOverflowValue(value) {
45
- return String(value || '')
46
- .trim()
47
- .toLowerCase()
48
- .split(/\s+/)
49
- .some((part) => part === 'hidden' || part === 'clip' || part === 'scroll' || part === 'auto');
50
- }
51
-
52
- function isSingleLineWhiteSpace(value) {
53
- const normalized = String(value || '').trim().toLowerCase();
54
- return normalized.includes('nowrap') || normalized === 'pre';
55
- }
56
-
57
- /**
58
- * CSS ellipsis requires text-overflow, non-wrapping inline text,
59
- * and clipping on either the text box or the parent cell/container.
60
- */
61
- export function shouldTruncateText(computed = {}, parentComputed = null) {
62
- const textOverflow = meaningfulTextOverflow(computed?.textOverflow)
63
- || meaningfulTextOverflow(parentComputed?.textOverflow);
64
-
65
- if (!textOverflow.includes('ellipsis')) {
66
- return false;
67
- }
68
-
69
- if (!isSingleLineWhiteSpace(computed?.whiteSpace) && !isSingleLineWhiteSpace(parentComputed?.whiteSpace)) {
70
- return false;
71
- }
72
-
73
- return overflowClipsInlineContent(computed) || overflowClipsInlineContent(parentComputed);
74
- }
75
 
76
  // ─── LAYOUT ──────────────────────────────────────────────────────────────────
77
 
78
  /**
79
  * display: flex β†’ Figma Auto Layout
80
  */
81
- export function mapFlexLayout(computed) {
82
- const isRow = computed.flexDirection !== 'column' && computed.flexDirection !== 'column-reverse';
83
- const columnGap = parsePx(computed.columnGap || computed.gap);
84
- const rowGap = parsePx(computed.rowGap || computed.gap);
85
- const wraps = computed.flexWrap === 'wrap' || computed.flexWrap === 'wrap-reverse';
86
- const result = {
87
- layoutMode: isRow ? 'HORIZONTAL' : 'VERTICAL',
88
- primaryAxisAlignItems: JUSTIFY_MAP[computed.justifyContent] ?? 'MIN',
89
- counterAxisAlignItems: ALIGN_MAP[computed.alignItems] ?? 'MIN',
90
- itemSpacing: isRow ? columnGap : rowGap,
91
- };
92
-
93
- if (wraps) {
94
- result.layoutWrap = 'WRAP';
95
- result.counterAxisSpacing = isRow ? rowGap : columnGap;
96
- }
97
-
98
- return result;
99
- }
100
 
101
  /**
102
  * padding β†’ Figma frame padding
@@ -113,13 +113,13 @@ export function mapPadding(computed) {
113
  /**
114
  * overflow β†’ Figma clipsContent
115
  */
116
- export function mapOverflow(computed) {
117
- return {
118
- clipsContent: isClippingOverflowValue(computed.overflow)
119
- || isClippingOverflowValue(computed.overflowX)
120
- || isClippingOverflowValue(computed.overflowY),
121
- };
122
- }
123
 
124
  /**
125
  * border-radius β†’ Figma cornerRadius
@@ -180,11 +180,38 @@ export function parseLinearGradient(cssGradient) {
180
  };
181
  }
182
 
183
- export function parseLinearGradientLayers(cssBackgroundImage) {
184
- return splitCssLayers(cssBackgroundImage)
185
- .filter((layer) => /^linear-gradient\(/i.test(layer.trim()))
186
- .map((layer) => parseLinearGradient(layer));
187
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
  function linearGradientTransform(cssGradient) {
190
  const angle = parseLinearGradientAngle(cssGradient);
@@ -228,26 +255,404 @@ function parseLinearGradientAngle(cssGradient) {
228
  return 180;
229
  }
230
 
231
- function extractGradientStops(css) {
232
- const stopRegex = /(rgba?\([^)]+\)|#[0-9a-f]{3,8}|transparent)\s*([\d.]+%)?/gi;
233
- const stops = [];
234
- let match;
235
- let index = 0;
236
-
237
- while ((match = stopRegex.exec(css)) !== null) {
238
- const color = cssColorToFigma(match[1]);
239
- const position = match[2] ? parseFloat(match[2]) / 100 : index === 0 ? 0 : 1;
240
- stops.push({ color, position });
241
- index++;
242
- }
243
-
244
- return stops.length > 0 ? stops : [
245
- { color: { r: 0, g: 0, b: 0, a: 0 }, position: 0 },
246
- { color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 },
247
- ];
248
- }
249
-
250
- function splitCssLayers(css) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  const source = String(css || '');
252
  const layers = [];
253
  let current = '';
@@ -419,25 +824,108 @@ function normalizeCssValue(value) {
419
  export function mapBoxShadow(computed) {
420
  if (!computed.boxShadow || computed.boxShadow === 'none') return [];
421
 
422
- const parts = computed.boxShadow.match(
423
- /(?:(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
424
- );
425
- if (!parts) return [];
426
 
427
- const [, leadingColor, x, y, blur, spread = '0px', trailingColor] = parts;
428
- const colorStr = leadingColor || trailingColor;
429
- if (!colorStr) return [];
430
- const color = cssColorToFigma(colorStr);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
- return [{
433
  type: 'DROP_SHADOW',
434
- color: { r: color.r, g: color.g, b: color.b, a: color.a },
435
- offset: { x: parsePx(x), y: parsePx(y) },
436
- radius: parsePx(blur),
437
- spread: parsePx(spread),
438
  visible: true,
439
  blendMode: 'NORMAL',
440
- }];
 
 
 
 
 
441
  }
442
 
443
  // ─── TYPOGRAPHY ───────────────────────────────────────────────────────────────
@@ -453,105 +941,107 @@ export function mapTypography(computed, fontMap = {}, parentComputed = null) {
453
 
454
  const font = fontMap?.[fontKey] ?? { family: familyRaw, style: 'Regular' };
455
  const fontSize = parsePx(computed.fontSize) || 16;
 
456
 
457
- const result = {
458
- fontName: font,
459
- fontSize,
460
- lineHeight: lineHeightToFigma(computed.lineHeight, computed.fontSize),
461
- letterSpacing: {
462
- value: letterSpacingToPx(computed.letterSpacing, computed.fontSize),
463
  unit: 'PIXELS',
464
  },
465
  textAlignHorizontal: TEXT_ALIGN_MAP[computed.textAlign] ?? 'LEFT',
466
- textCase: TEXT_CASE_MAP[computed.textTransform] ?? 'ORIGINAL',
467
- // -webkit-text-stroke β†’ outline text (color: transparent + stroke)
468
- fills: isTransparentCssColor(computed.color)
469
- ? []
470
- : [solidPaint(computed.color)],
471
- ...mapTextDecoration(computed),
472
- };
473
-
474
- if (shouldTruncateText(computed, parentComputed)) {
475
- result.textTruncation = 'ENDING';
476
- }
477
-
478
- return result;
479
- }
480
-
481
- function mapTextDecoration(computed = {}) {
482
- const line = String(computed.textDecorationLine || computed.textDecoration || '').toLowerCase();
483
- const hasUnderline = line.includes('underline');
484
- const hasStrikethrough = line.includes('line-through');
485
- if (!hasUnderline && !hasStrikethrough) {
486
- return {};
487
- }
488
-
489
- const result = {
490
- textDecoration: hasUnderline ? 'UNDERLINE' : 'STRIKETHROUGH',
491
- };
492
-
493
- const style = mapTextDecorationStyle(computed.textDecorationStyle || computed.textDecoration);
494
- if (style) {
495
- result.textDecorationStyle = style;
496
- }
497
-
498
- const color = mapTextDecorationColor(computed.textDecorationColor);
499
- if (color) {
500
- result.textDecorationColor = color;
501
- }
502
-
503
- const thickness = mapTextDecorationThickness(computed.textDecorationThickness);
504
- if (thickness) {
505
- result.textDecorationThickness = thickness;
506
- }
507
-
508
- return result;
509
- }
510
-
511
- function mapTextDecorationStyle(value) {
512
- const normalized = String(value || '').toLowerCase();
513
- if (normalized.includes('dotted')) return 'DOTTED';
514
- if (normalized.includes('wavy')) return 'WAVY';
515
- if (normalized.includes('solid')) return 'SOLID';
516
- return null;
517
- }
518
-
519
- function mapTextDecorationColor(value) {
520
- if (!value || value === 'currentcolor' || value === 'currentColor') {
521
- return null;
522
- }
523
-
524
- const color = cssColorToFigma(value);
525
- if (color.a === 0) {
526
- return null;
527
- }
528
-
529
- return {
530
- value: {
531
- type: 'SOLID',
532
- color: { r: color.r, g: color.g, b: color.b },
533
- opacity: color.a,
534
- },
535
- };
536
- }
537
-
538
- function mapTextDecorationThickness(value) {
539
- const raw = String(value || '').trim().toLowerCase();
540
- if (!raw || raw === 'auto' || raw === 'from-font') {
541
- return null;
542
- }
543
-
544
- if (raw.endsWith('%')) {
545
- const percent = parseFloat(raw);
546
- return Number.isFinite(percent) ? { value: percent, unit: 'PERCENT' } : null;
547
- }
548
-
549
- const px = parsePx(raw);
550
- return px > 0 ? { value: px, unit: 'PIXELS' } : null;
551
- }
552
-
553
- /**
554
- * Map -webkit-text-stroke to Figma strokes on a text node.
 
555
  */
556
  export function mapTextStroke(computed) {
557
  // CSS doesn't expose webkit-text-stroke in getComputedStyle reliably,
 
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
+ return isClippingOverflowValue(computed[prop]);
41
+ });
42
+ }
43
+
44
+ function isClippingOverflowValue(value) {
45
+ return String(value || '')
46
+ .trim()
47
+ .toLowerCase()
48
+ .split(/\s+/)
49
+ .some((part) => part === 'hidden' || part === 'clip' || part === 'scroll' || part === 'auto');
50
+ }
51
+
52
+ function isSingleLineWhiteSpace(value) {
53
+ const normalized = String(value || '').trim().toLowerCase();
54
+ return normalized.includes('nowrap') || normalized === 'pre';
55
+ }
56
+
57
+ /**
58
+ * CSS ellipsis requires text-overflow, non-wrapping inline text,
59
+ * and clipping on either the text box or the parent cell/container.
60
+ */
61
+ export function shouldTruncateText(computed = {}, parentComputed = null) {
62
+ const textOverflow = meaningfulTextOverflow(computed?.textOverflow)
63
+ || meaningfulTextOverflow(parentComputed?.textOverflow);
64
+
65
+ if (!textOverflow.includes('ellipsis')) {
66
+ return false;
67
+ }
68
+
69
+ if (!isSingleLineWhiteSpace(computed?.whiteSpace) && !isSingleLineWhiteSpace(parentComputed?.whiteSpace)) {
70
+ return false;
71
+ }
72
+
73
+ return overflowClipsInlineContent(computed) || overflowClipsInlineContent(parentComputed);
74
+ }
75
 
76
  // ─── LAYOUT ──────────────────────────────────────────────────────────────────
77
 
78
  /**
79
  * display: flex β†’ Figma Auto Layout
80
  */
81
+ export function mapFlexLayout(computed) {
82
+ const isRow = computed.flexDirection !== 'column' && computed.flexDirection !== 'column-reverse';
83
+ const columnGap = parsePx(computed.columnGap || computed.gap);
84
+ const rowGap = parsePx(computed.rowGap || computed.gap);
85
+ const wraps = computed.flexWrap === 'wrap' || computed.flexWrap === 'wrap-reverse';
86
+ const result = {
87
+ layoutMode: isRow ? 'HORIZONTAL' : 'VERTICAL',
88
+ primaryAxisAlignItems: JUSTIFY_MAP[computed.justifyContent] ?? 'MIN',
89
+ counterAxisAlignItems: ALIGN_MAP[computed.alignItems] ?? 'MIN',
90
+ itemSpacing: isRow ? columnGap : rowGap,
91
+ };
92
+
93
+ if (wraps) {
94
+ result.layoutWrap = 'WRAP';
95
+ result.counterAxisSpacing = isRow ? rowGap : columnGap;
96
+ }
97
+
98
+ return result;
99
+ }
100
 
101
  /**
102
  * padding β†’ Figma frame padding
 
113
  /**
114
  * overflow β†’ Figma clipsContent
115
  */
116
+ export function mapOverflow(computed) {
117
+ return {
118
+ clipsContent: isClippingOverflowValue(computed.overflow)
119
+ || isClippingOverflowValue(computed.overflowX)
120
+ || isClippingOverflowValue(computed.overflowY),
121
+ };
122
+ }
123
 
124
  /**
125
  * border-radius β†’ Figma cornerRadius
 
180
  };
181
  }
182
 
183
+ export function parseLinearGradientLayers(cssBackgroundImage) {
184
+ return splitCssLayers(cssBackgroundImage)
185
+ .filter((layer) => /^linear-gradient\(/i.test(layer.trim()))
186
+ .map((layer) => parseLinearGradient(layer));
187
+ }
188
+
189
+ export function parseGradientLayers(cssBackgroundImage, rect = null) {
190
+ return splitCssLayers(cssBackgroundImage)
191
+ .map((layer) => parseGradientLayer(layer, rect))
192
+ .filter(Boolean);
193
+ }
194
+
195
+ export function parseGradientLayer(cssGradient, rect = null) {
196
+ const source = String(cssGradient || '').trim();
197
+ if (/^linear-gradient\(/i.test(source)) {
198
+ return parseLinearGradient(source);
199
+ }
200
+ if (/^radial-gradient\(/i.test(source)) {
201
+ return parseRadialGradient(source, rect);
202
+ }
203
+ return null;
204
+ }
205
+
206
+ export function parseRadialGradient(cssGradient, rect = null) {
207
+ const geometry = parseRadialGradientGeometry(cssGradient, rect);
208
+
209
+ return {
210
+ type: 'GRADIENT_RADIAL',
211
+ gradientTransform: radialGradientTransform(geometry),
212
+ gradientStops: extractGradientStops(cssGradient),
213
+ };
214
+ }
215
 
216
  function linearGradientTransform(cssGradient) {
217
  const angle = parseLinearGradientAngle(cssGradient);
 
255
  return 180;
256
  }
257
 
258
+ function extractGradientStops(css) {
259
+ const stopRegex = /(rgba?\([^)]+\)|#[0-9a-f]{3,8}|transparent)\s*([\d.]+%)?/gi;
260
+ const stops = [];
261
+ let match;
262
+
263
+ while ((match = stopRegex.exec(css)) !== null) {
264
+ const color = cssColorToFigma(match[1]);
265
+ const position = match[2] ? parseFloat(match[2]) / 100 : null;
266
+ stops.push({
267
+ color,
268
+ position: Number.isFinite(position) ? position : null,
269
+ rawColor: match[1],
270
+ });
271
+ }
272
+
273
+ if (stops.length === 0) {
274
+ return fallbackTransparentGradientStops();
275
+ }
276
+
277
+ return normalizeTransparentGradientStops(normalizeGradientStopPositions(stops))
278
+ .map((stop) => ({
279
+ color: stop.color,
280
+ position: stop.position,
281
+ }));
282
+ }
283
+
284
+ function normalizeGradientStopPositions(stops) {
285
+ const result = stops.map((stop) => ({ ...stop }));
286
+ const lastIndex = result.length - 1;
287
+
288
+ if (result[0].position === null) {
289
+ result[0].position = 0;
290
+ }
291
+ if (result[lastIndex].position === null) {
292
+ result[lastIndex].position = lastIndex === 0 ? result[0].position : 1;
293
+ }
294
+
295
+ let index = 0;
296
+ while (index < result.length) {
297
+ if (result[index].position !== null) {
298
+ index++;
299
+ continue;
300
+ }
301
+
302
+ const startIndex = index - 1;
303
+ let endIndex = index + 1;
304
+ while (endIndex < result.length && result[endIndex].position === null) {
305
+ endIndex++;
306
+ }
307
+
308
+ const startPosition = result[startIndex]?.position ?? 0;
309
+ const endPosition = result[endIndex]?.position ?? 1;
310
+ const gap = endIndex - startIndex;
311
+ for (let fillIndex = index; fillIndex < endIndex; fillIndex++) {
312
+ const step = fillIndex - startIndex;
313
+ result[fillIndex].position = startPosition + ((endPosition - startPosition) * step) / gap;
314
+ }
315
+
316
+ index = endIndex;
317
+ }
318
+
319
+ let previous = 0;
320
+ for (let stopIndex = 0; stopIndex < result.length; stopIndex++) {
321
+ const position = Number.isFinite(result[stopIndex].position) ? result[stopIndex].position : previous;
322
+ previous = Math.max(previous, Math.min(Math.max(position, 0), 1));
323
+ result[stopIndex].position = previous;
324
+ }
325
+
326
+ return result;
327
+ }
328
+
329
+ function normalizeTransparentGradientStops(stops) {
330
+ return stops.map((stop, index) => {
331
+ if ((stop.color?.a ?? 1) > 0) {
332
+ return stop;
333
+ }
334
+
335
+ const neighboringColor = findNeighboringOpaqueStopColor(stops, index);
336
+ if (!neighboringColor) {
337
+ return stop;
338
+ }
339
+
340
+ return {
341
+ ...stop,
342
+ color: {
343
+ r: neighboringColor.r,
344
+ g: neighboringColor.g,
345
+ b: neighboringColor.b,
346
+ a: 0,
347
+ },
348
+ };
349
+ });
350
+ }
351
+
352
+ function findNeighboringOpaqueStopColor(stops, index) {
353
+ for (let before = index - 1; before >= 0; before--) {
354
+ if ((stops[before].color?.a ?? 1) > 0) {
355
+ return stops[before].color;
356
+ }
357
+ }
358
+ for (let after = index + 1; after < stops.length; after++) {
359
+ if ((stops[after].color?.a ?? 1) > 0) {
360
+ return stops[after].color;
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+
366
+ function fallbackTransparentGradientStops() {
367
+ return [
368
+ { color: { r: 0, g: 0, b: 0, a: 0 }, position: 0 },
369
+ { color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 },
370
+ ];
371
+ }
372
+
373
+ function parseRadialGradientGeometry(cssGradient, rect = null) {
374
+ const args = getGradientArguments(cssGradient);
375
+ const prelude = getRadialGradientPrelude(args);
376
+ const atParts = splitRadialPreludeAt(prelude);
377
+ const shape = /\bcircle\b/i.test(atParts.size) ? 'circle' : 'ellipse';
378
+ const size = atParts.size
379
+ .replace(/\b(circle|ellipse)\b/gi, ' ')
380
+ .replace(/\s+/g, ' ')
381
+ .trim();
382
+ const center = parseRadialPosition(atParts.position, rect);
383
+ const radii = parseRadialRadii(size, shape, center, rect);
384
+
385
+ return {
386
+ center,
387
+ radiusX: Math.max(radii.radiusX, 0.0001),
388
+ radiusY: Math.max(radii.radiusY, 0.0001),
389
+ };
390
+ }
391
+
392
+ function getGradientArguments(cssGradient) {
393
+ const source = String(cssGradient || '').trim();
394
+ const openIndex = source.indexOf('(');
395
+ const closeIndex = source.lastIndexOf(')');
396
+ if (openIndex === -1 || closeIndex <= openIndex) {
397
+ return [];
398
+ }
399
+ return splitCssLayers(source.slice(openIndex + 1, closeIndex));
400
+ }
401
+
402
+ function getRadialGradientPrelude(args) {
403
+ const first = (args && args[0] ? String(args[0]) : '').trim();
404
+ return isRadialGradientPrelude(first) ? first : '';
405
+ }
406
+
407
+ function isRadialGradientPrelude(value) {
408
+ const source = String(value || '').trim();
409
+ if (!source) {
410
+ return false;
411
+ }
412
+ const lower = source.toLowerCase();
413
+ return /\bat\b/.test(lower)
414
+ || /\b(circle|ellipse|closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)\b/.test(lower)
415
+ || /^[\d.]+(?:%|px)?(?:\s+[\d.]+(?:%|px)?)?$/.test(lower);
416
+ }
417
+
418
+ function splitRadialPreludeAt(prelude) {
419
+ const source = String(prelude || '').trim();
420
+ const match = source.match(/\bat\b/i);
421
+ if (!match) {
422
+ return { size: source, position: '' };
423
+ }
424
+
425
+ return {
426
+ size: source.slice(0, match.index).trim(),
427
+ position: source.slice(match.index + match[0].length).trim(),
428
+ };
429
+ }
430
+
431
+ function parseRadialPosition(position, rect = null) {
432
+ const source = String(position || '').trim().toLowerCase();
433
+ if (!source) {
434
+ return { x: 0.5, y: 0.5 };
435
+ }
436
+
437
+ const tokens = source.split(/\s+/).filter(Boolean);
438
+ if (tokens.length >= 4) {
439
+ return parseFourTokenRadialPosition(tokens, rect);
440
+ }
441
+
442
+ let x = null;
443
+ let y = null;
444
+ for (const token of tokens) {
445
+ if (token === 'center') {
446
+ if (x === null) {
447
+ x = 0.5;
448
+ } else if (y === null) {
449
+ y = 0.5;
450
+ }
451
+ } else if (isHorizontalPositionKeyword(token)) {
452
+ x = positionKeywordToValue(token);
453
+ } else if (isVerticalPositionKeyword(token)) {
454
+ y = positionKeywordToValue(token);
455
+ } else if (x === null) {
456
+ x = parsePositionLength(token, rect?.width);
457
+ } else if (y === null) {
458
+ y = parsePositionLength(token, rect?.height);
459
+ }
460
+ }
461
+
462
+ return {
463
+ x: clampUnit(x === null ? 0.5 : x),
464
+ y: clampUnit(y === null ? 0.5 : y),
465
+ };
466
+ }
467
+
468
+ function parseFourTokenRadialPosition(tokens, rect = null) {
469
+ let x = 0.5;
470
+ let y = 0.5;
471
+ for (let index = 0; index < tokens.length - 1; index += 2) {
472
+ const edge = tokens[index];
473
+ const offset = tokens[index + 1];
474
+ if (edge === 'left') {
475
+ x = parsePositionLength(offset, rect?.width);
476
+ } else if (edge === 'right') {
477
+ x = 1 - parsePositionLength(offset, rect?.width);
478
+ } else if (edge === 'top') {
479
+ y = parsePositionLength(offset, rect?.height);
480
+ } else if (edge === 'bottom') {
481
+ y = 1 - parsePositionLength(offset, rect?.height);
482
+ }
483
+ }
484
+ return { x: clampUnit(x), y: clampUnit(y) };
485
+ }
486
+
487
+ function isHorizontalPositionKeyword(token) {
488
+ return token === 'left' || token === 'right';
489
+ }
490
+
491
+ function isVerticalPositionKeyword(token) {
492
+ return token === 'top' || token === 'bottom';
493
+ }
494
+
495
+ function positionKeywordToValue(token) {
496
+ if (token === 'left' || token === 'top') return 0;
497
+ if (token === 'right' || token === 'bottom') return 1;
498
+ return 0.5;
499
+ }
500
+
501
+ function parsePositionLength(token, axisLength) {
502
+ const source = String(token || '').trim();
503
+ if (source.endsWith('%')) {
504
+ return parseFloat(source) / 100;
505
+ }
506
+ if (source.endsWith('px') && axisLength > 0) {
507
+ return parsePx(source) / axisLength;
508
+ }
509
+ const value = parseFloat(source);
510
+ return Number.isFinite(value) ? value : 0.5;
511
+ }
512
+
513
+ function parseRadialRadii(size, shape, center, rect = null) {
514
+ const source = String(size || '').trim().toLowerCase();
515
+ const explicit = extractRadialSizeTokens(source);
516
+ if (explicit.length > 0) {
517
+ return parseExplicitRadialRadii(explicit, shape, rect);
518
+ }
519
+
520
+ const keyword = source.match(/\b(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)\b/)?.[1]
521
+ || 'farthest-corner';
522
+ return keywordRadialRadii(keyword, shape, center, rect);
523
+ }
524
+
525
+ function extractRadialSizeTokens(value) {
526
+ return (String(value || '').match(/(?:\d*\.)?\d+(?:%|px)?/g) || [])
527
+ .filter((token) => Number.isFinite(parseFloat(token)));
528
+ }
529
+
530
+ function parseExplicitRadialRadii(tokens, shape, rect = null) {
531
+ if (shape === 'circle') {
532
+ if (String(tokens[0]).endsWith('px') && rect?.width > 0 && rect?.height > 0) {
533
+ const px = parsePx(tokens[0]);
534
+ return {
535
+ radiusX: px / rect.width,
536
+ radiusY: px / rect.height,
537
+ };
538
+ }
539
+
540
+ const radius = parseRadialRadiusToken(tokens[0], Math.min(rect?.width || 0, rect?.height || 0));
541
+ return { radiusX: radius, radiusY: radius };
542
+ }
543
+
544
+ return {
545
+ radiusX: parseRadialRadiusToken(tokens[0], rect?.width),
546
+ radiusY: parseRadialRadiusToken(tokens[1] || tokens[0], rect?.height),
547
+ };
548
+ }
549
+
550
+ function parseRadialRadiusToken(token, axisLength) {
551
+ const source = String(token || '').trim();
552
+ if (source.endsWith('%')) {
553
+ return parseFloat(source) / 100;
554
+ }
555
+ if (source.endsWith('px') && axisLength > 0) {
556
+ return parsePx(source) / axisLength;
557
+ }
558
+ const value = parseFloat(source);
559
+ return Number.isFinite(value) ? value : 0.5;
560
+ }
561
+
562
+ function keywordRadialRadii(keyword, shape, center, rect = null) {
563
+ if (shape === 'circle') {
564
+ const radiusPx = keywordCircleRadius(keyword, center, rect);
565
+ const width = rect?.width || 1;
566
+ const height = rect?.height || 1;
567
+ return {
568
+ radiusX: radiusPx / width,
569
+ radiusY: radiusPx / height,
570
+ };
571
+ }
572
+
573
+ const closestX = Math.min(center.x, 1 - center.x);
574
+ const closestY = Math.min(center.y, 1 - center.y);
575
+ const farthestX = Math.max(center.x, 1 - center.x);
576
+ const farthestY = Math.max(center.y, 1 - center.y);
577
+
578
+ if (keyword === 'closest-side' || keyword === 'contain') {
579
+ return { radiusX: closestX, radiusY: closestY };
580
+ }
581
+ if (keyword === 'farthest-side') {
582
+ return { radiusX: farthestX, radiusY: farthestY };
583
+ }
584
+ if (keyword === 'closest-corner') {
585
+ return { radiusX: closestX * Math.SQRT2, radiusY: closestY * Math.SQRT2 };
586
+ }
587
+ return { radiusX: farthestX * Math.SQRT2, radiusY: farthestY * Math.SQRT2 };
588
+ }
589
+
590
+ function keywordCircleRadius(keyword, center, rect = null) {
591
+ const width = rect?.width || 1;
592
+ const height = rect?.height || 1;
593
+ const left = center.x * width;
594
+ const right = (1 - center.x) * width;
595
+ const top = center.y * height;
596
+ const bottom = (1 - center.y) * height;
597
+
598
+ if (keyword === 'closest-side' || keyword === 'contain') {
599
+ return Math.min(left, right, top, bottom);
600
+ }
601
+ if (keyword === 'farthest-side') {
602
+ return Math.max(left, right, top, bottom);
603
+ }
604
+
605
+ const distances = [
606
+ Math.hypot(left, top),
607
+ Math.hypot(right, top),
608
+ Math.hypot(left, bottom),
609
+ Math.hypot(right, bottom),
610
+ ];
611
+ return keyword === 'closest-corner' ? Math.min(...distances) : Math.max(...distances);
612
+ }
613
+
614
+ function radialGradientTransform({ center, radiusX, radiusY }) {
615
+ const centerHandle = { x: center.x, y: center.y };
616
+ const radiusYHandle = { x: center.x, y: center.y + radiusY };
617
+ const radiusXHandle = { x: center.x + radiusX, y: center.y };
618
+ return gradientTransformFromHandles(centerHandle, radiusYHandle, radiusXHandle);
619
+ }
620
+
621
+ function gradientTransformFromHandles(start, end, width) {
622
+ const ux = end.x - start.x;
623
+ const uy = end.y - start.y;
624
+ const vx = width.x - start.x;
625
+ const vy = width.y - start.y;
626
+ const det = ux * vy - vx * uy;
627
+
628
+ if (Math.abs(det) < 1e-8) {
629
+ return [
630
+ [1, 0, 0],
631
+ [0, 1, 0],
632
+ ];
633
+ }
634
+
635
+ const m00 = vy / det;
636
+ const m01 = -vx / det;
637
+ const m10 = 0.5 * uy / det;
638
+ const m11 = -0.5 * ux / det;
639
+ const tx = -m00 * start.x - m01 * start.y;
640
+ const ty = 0.5 - m10 * start.x - m11 * start.y;
641
+
642
+ return [
643
+ [normalizeZero(m00), normalizeZero(m01), normalizeZero(tx)],
644
+ [normalizeZero(m10), normalizeZero(m11), normalizeZero(ty)],
645
+ ];
646
+ }
647
+
648
+ function clampUnit(value) {
649
+ if (!Number.isFinite(value)) {
650
+ return 0.5;
651
+ }
652
+ return Math.max(0, Math.min(value, 1));
653
+ }
654
+
655
+ export function splitCssLayers(css) {
656
  const source = String(css || '');
657
  const layers = [];
658
  let current = '';
 
824
  export function mapBoxShadow(computed) {
825
  if (!computed.boxShadow || computed.boxShadow === 'none') return [];
826
 
827
+ return splitCssLayers(computed.boxShadow)
828
+ .map((layer) => parseShadowEffect(layer, true))
829
+ .filter(Boolean);
830
+ }
831
 
832
+ export function mapTextEffects(computed = {}) {
833
+ return [
834
+ ...mapTextShadow(computed),
835
+ ...mapDropShadowFilter(computed),
836
+ ];
837
+ }
838
+
839
+ export function mapTextShadow(computed = {}) {
840
+ if (!computed.textShadow || computed.textShadow === 'none') return [];
841
+
842
+ return splitCssLayers(computed.textShadow)
843
+ .map((layer) => parseShadowEffect(layer, false))
844
+ .filter(Boolean);
845
+ }
846
+
847
+ export function mapDropShadowFilter(computed = {}) {
848
+ const filter = String(computed.filter || '').trim();
849
+ if (!filter || filter === 'none' || !filter.includes('drop-shadow(')) return [];
850
+
851
+ const effects = [];
852
+ const regex = /drop-shadow\(([^)]+(?:\)[^)]*)?)\)/gi;
853
+ let match;
854
+ while ((match = regex.exec(filter)) !== null) {
855
+ const effect = parseShadowEffect(match[1], false);
856
+ if (effect) {
857
+ effects.push(effect);
858
+ }
859
+ }
860
+ return effects;
861
+ }
862
+
863
+ const CSS_NAMED_COLORS = new Set([
864
+ 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond',
865
+ 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue',
866
+ 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey',
867
+ 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen',
868
+ 'darkslate50', 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink',
869
+ 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia',
870
+ 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink',
871
+ 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue',
872
+ 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink',
873
+ 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue',
874
+ 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid',
875
+ 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred',
876
+ 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab',
877
+ 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip',
878
+ 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue',
879
+ 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue',
880
+ 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise',
881
+ 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen', 'transparent'
882
+ ]);
883
+
884
+ function parseShadowEffect(value, allowSpread) {
885
+ const source = String(value || '').trim();
886
+
887
+ // 1. Check for functional colors or hex colors
888
+ const fnOrHexRegex = /(?:rgba?|hsla?|hwb|oklab|oklch|color)\([^)]+\)|#[0-9a-fA-F]{3,8}/gi;
889
+ const fnOrHexMatches = source.match(fnOrHexRegex) || [];
890
+ let colorStr = fnOrHexMatches[0];
891
+
892
+ let withoutColor = source;
893
+ if (colorStr) {
894
+ withoutColor = source.replace(colorStr, ' ');
895
+ } else {
896
+ // 2. Check for named colors
897
+ const words = source.match(/\b[a-zA-Z-]+\b/g) || [];
898
+ for (const word of words) {
899
+ const lowerWord = word.toLowerCase();
900
+ if (CSS_NAMED_COLORS.has(lowerWord) && lowerWord !== 'inset') {
901
+ colorStr = word;
902
+ withoutColor = source.replace(new RegExp(`\\b${word}\\b`, 'gi'), ' ');
903
+ break;
904
+ }
905
+ }
906
+ }
907
+
908
+ if (!colorStr) return null;
909
+
910
+ // Clean "inset" keyword if present
911
+ const cleaned = withoutColor.replace(/\binset\b/gi, ' ').trim();
912
+ const lengths = cleaned.match(/-?[\d.]+(?:px|em|rem|vh|vw|%)?|0/gi) || [];
913
+ if (lengths.length < 2) return null;
914
 
915
+ return {
916
  type: 'DROP_SHADOW',
917
+ color: shadowColor(colorStr),
918
+ offset: { x: parsePx(lengths[0]), y: parsePx(lengths[1]) },
919
+ radius: parsePx(lengths[2] || '0px'),
920
+ spread: allowSpread ? parsePx(lengths[3] || '0px') : 0,
921
  visible: true,
922
  blendMode: 'NORMAL',
923
+ };
924
+ }
925
+
926
+ function shadowColor(colorStr) {
927
+ const color = cssColorToFigma(colorStr);
928
+ return { r: color.r, g: color.g, b: color.b, a: color.a };
929
  }
930
 
931
  // ─── TYPOGRAPHY ───────────────────────────────────────────────────────────────
 
941
 
942
  const font = fontMap?.[fontKey] ?? { family: familyRaw, style: 'Regular' };
943
  const fontSize = parsePx(computed.fontSize) || 16;
944
+ const effects = mapTextEffects(computed);
945
 
946
+ const result = {
947
+ fontName: font,
948
+ fontSize,
949
+ lineHeight: lineHeightToFigma(computed.lineHeight, computed.fontSize),
950
+ letterSpacing: {
951
+ value: letterSpacingToPx(computed.letterSpacing, computed.fontSize),
952
  unit: 'PIXELS',
953
  },
954
  textAlignHorizontal: TEXT_ALIGN_MAP[computed.textAlign] ?? 'LEFT',
955
+ textCase: TEXT_CASE_MAP[computed.textTransform] ?? 'ORIGINAL',
956
+ // -webkit-text-stroke β†’ outline text (color: transparent + stroke)
957
+ fills: isTransparentCssColor(computed.color)
958
+ ? [solidPaint('transparent')]
959
+ : [solidPaint(computed.color)],
960
+ ...mapTextDecoration(computed),
961
+ ...(effects.length ? { effects } : {}),
962
+ };
963
+
964
+ if (shouldTruncateText(computed, parentComputed)) {
965
+ result.textTruncation = 'ENDING';
966
+ }
967
+
968
+ return result;
969
+ }
970
+
971
+ function mapTextDecoration(computed = {}) {
972
+ const line = String(computed.textDecorationLine || computed.textDecoration || '').toLowerCase();
973
+ const hasUnderline = line.includes('underline');
974
+ const hasStrikethrough = line.includes('line-through');
975
+ if (!hasUnderline && !hasStrikethrough) {
976
+ return {};
977
+ }
978
+
979
+ const result = {
980
+ textDecoration: hasUnderline ? 'UNDERLINE' : 'STRIKETHROUGH',
981
+ };
982
+
983
+ const style = mapTextDecorationStyle(computed.textDecorationStyle || computed.textDecoration);
984
+ if (style) {
985
+ result.textDecorationStyle = style;
986
+ }
987
+
988
+ const color = mapTextDecorationColor(computed.textDecorationColor);
989
+ if (color) {
990
+ result.textDecorationColor = color;
991
+ }
992
+
993
+ const thickness = mapTextDecorationThickness(computed.textDecorationThickness);
994
+ if (thickness) {
995
+ result.textDecorationThickness = thickness;
996
+ }
997
+
998
+ return result;
999
+ }
1000
+
1001
+ function mapTextDecorationStyle(value) {
1002
+ const normalized = String(value || '').toLowerCase();
1003
+ if (normalized.includes('dotted')) return 'DOTTED';
1004
+ if (normalized.includes('wavy')) return 'WAVY';
1005
+ if (normalized.includes('solid')) return 'SOLID';
1006
+ return null;
1007
+ }
1008
+
1009
+ function mapTextDecorationColor(value) {
1010
+ if (!value || value === 'currentcolor' || value === 'currentColor') {
1011
+ return null;
1012
+ }
1013
+
1014
+ const color = cssColorToFigma(value);
1015
+ if (color.a === 0) {
1016
+ return null;
1017
+ }
1018
+
1019
+ return {
1020
+ value: {
1021
+ type: 'SOLID',
1022
+ color: { r: color.r, g: color.g, b: color.b },
1023
+ opacity: color.a,
1024
+ },
1025
+ };
1026
+ }
1027
+
1028
+ function mapTextDecorationThickness(value) {
1029
+ const raw = String(value || '').trim().toLowerCase();
1030
+ if (!raw || raw === 'auto' || raw === 'from-font') {
1031
+ return null;
1032
+ }
1033
+
1034
+ if (raw.endsWith('%')) {
1035
+ const percent = parseFloat(raw);
1036
+ return Number.isFinite(percent) ? { value: percent, unit: 'PERCENT' } : null;
1037
+ }
1038
+
1039
+ const px = parsePx(raw);
1040
+ return px > 0 ? { value: px, unit: 'PIXELS' } : null;
1041
+ }
1042
+
1043
+ /**
1044
+ * Map -webkit-text-stroke to Figma strokes on a text node.
1045
  */
1046
  export function mapTextStroke(computed) {
1047
  // CSS doesn't expose webkit-text-stroke in getComputedStyle reliably,
src/figma/font-resolver.js CHANGED
@@ -10,21 +10,21 @@
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',
16
- 'Thin Italic', 'ExtraLight Italic', 'Light Italic', 'Italic', 'Medium Italic',
17
- 'SemiBold Italic', 'Bold Italic', 'ExtraBold Italic', 'Black Italic'],
18
- 'DM Sans': ['Thin', 'ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black',
19
- 'Thin Italic', 'ExtraLight Italic', 'Light Italic', 'Italic', 'Medium Italic',
20
- 'SemiBold Italic', 'Bold Italic', 'ExtraBold Italic', 'Black Italic'],
21
- 'Bebas Neue': ['Regular'],
22
- 'Inter': ['Thin', 'ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black',
23
- 'Thin Italic', 'ExtraLight Italic', 'Light Italic', 'Italic', 'Medium Italic',
24
- 'SemiBold Italic', 'Bold Italic', 'ExtraBold Italic', 'Black Italic'],
25
- Georgia: ['Regular', 'Italic', 'Bold', 'Bold Italic'],
26
- 'Courier New': ['Regular', 'Italic', 'Bold', 'Bold Italic'],
27
- };
28
 
29
  /**
30
  * @param {object} domTree
@@ -33,34 +33,34 @@ const FIGMA_FONT_STYLES = {
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) {
39
- const key = `${fontFamily}|${fontWeight ?? '400'}|${fontStyle ?? 'normal'}`;
40
- needed.add(key);
41
- }
42
-
43
- for (const run of node.textRuns ?? []) {
44
- const runFamily = run.computed?.fontFamily;
45
- if (!runFamily) continue;
46
- const key = `${runFamily}|${run.computed?.fontWeight ?? '400'}|${run.computed?.fontStyle ?? 'normal'}`;
47
- needed.add(key);
48
- }
49
-
50
- for (const pseudo of [node.pseudo?.before, node.pseudo?.after]) {
51
- const pseudoFamily = pseudo?.computed?.fontFamily;
52
- if (!pseudoFamily) continue;
53
- const key = `${pseudoFamily}|${pseudo.computed?.fontWeight ?? '400'}|${pseudo.computed?.fontStyle ?? 'normal'}`;
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
  }
@@ -69,104 +69,104 @@ export async function resolveFonts(domTree) {
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
- }
75
-
76
- /**
77
- * Resolve to a specific Figma font, with fallback chain.
78
- */
79
- function resolveFont(cssFamily, weightStr, isItalic) {
80
- const stack = getFontFamilyStack(cssFamily);
81
- const family = cleanFamilyName(cssFamily);
82
- const weight = parseInt(weightStr) || 400;
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') {
97
- candidates.push('Georgia');
98
- } else if (generic === 'monospace') {
99
- candidates.push('Courier New');
100
- } else {
101
- candidates.push('Inter');
102
- }
103
- }
104
-
105
- if (family && family !== candidates[0] && FIGMA_FONT_STYLES[family]) {
106
- candidates.push(family);
107
- }
108
-
109
- if (!candidates.includes('Inter')) {
110
- candidates.push('Inter');
111
- }
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
- }
121
- if (styles.includes('Regular')) {
122
- return { family: candidate, style: 'Regular' };
123
- }
124
- }
125
-
126
- return { family: 'Inter', style: 'Regular' };
127
- }
128
-
129
- function getFontFamilyStack(cssFamily) {
130
- return String(cssFamily || '')
131
- .split(',')
132
- .map((part) => part.trim().replace(/['"]/g, ''))
133
- .filter(Boolean);
134
- }
135
-
136
- function getGenericFontFamily(stack) {
137
- if (!Array.isArray(stack) || stack.length === 0) {
138
- return null;
139
- }
140
-
141
- const last = stack[stack.length - 1].toLowerCase();
142
- if (last === 'serif' || last === 'sans-serif' || last === 'monospace') {
143
- return last;
144
- }
145
-
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
 
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',
16
+ 'Thin Italic', 'ExtraLight Italic', 'Light Italic', 'Italic', 'Medium Italic',
17
+ 'SemiBold Italic', 'Bold Italic', 'ExtraBold Italic', 'Black Italic'],
18
+ 'DM Sans': ['Thin', 'ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black',
19
+ 'Thin Italic', 'ExtraLight Italic', 'Light Italic', 'Italic', 'Medium Italic',
20
+ 'SemiBold Italic', 'Bold Italic', 'ExtraBold Italic', 'Black Italic'],
21
+ 'Bebas Neue': ['Regular'],
22
+ 'Inter': ['Thin', 'ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black',
23
+ 'Thin Italic', 'ExtraLight Italic', 'Light Italic', 'Italic', 'Medium Italic',
24
+ 'SemiBold Italic', 'Bold Italic', 'ExtraBold Italic', 'Black Italic'],
25
+ Georgia: ['Regular', 'Italic', 'Bold', 'Bold Italic'],
26
+ 'Courier New': ['Regular', 'Italic', 'Bold', 'Bold Italic'],
27
+ };
28
 
29
  /**
30
  * @param {object} domTree
 
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) {
39
+ const key = `${fontFamily}|${fontWeight ?? '400'}|${fontStyle ?? 'normal'}`;
40
+ needed.add(key);
41
+ }
42
+
43
+ for (const run of node.textRuns ?? []) {
44
+ const runFamily = run.computed?.fontFamily;
45
+ if (!runFamily) continue;
46
+ const key = `${runFamily}|${run.computed?.fontWeight ?? '400'}|${run.computed?.fontStyle ?? 'normal'}`;
47
+ needed.add(key);
48
+ }
49
+
50
+ for (const pseudo of [node.pseudo?.before, node.pseudo?.after]) {
51
+ const pseudoFamily = pseudo?.computed?.fontFamily;
52
+ if (!pseudoFamily) continue;
53
+ const key = `${pseudoFamily}|${pseudo.computed?.fontWeight ?? '400'}|${pseudo.computed?.fontStyle ?? 'normal'}`;
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
  }
 
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
+ }
75
+
76
+ /**
77
+ * Resolve to a specific Figma font, with fallback chain.
78
+ */
79
+ function resolveFont(cssFamily, weightStr, isItalic) {
80
+ const stack = getFontFamilyStack(cssFamily);
81
+ const family = cleanFamilyName(cssFamily);
82
+ const weight = parseInt(weightStr) || 400;
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') {
97
+ candidates.push('Georgia');
98
+ } else if (generic === 'monospace') {
99
+ candidates.push('Courier New');
100
+ } else {
101
+ candidates.push('Inter');
102
+ }
103
+ }
104
+
105
+ if (family && family !== candidates[0] && FIGMA_FONT_STYLES[family]) {
106
+ candidates.push(family);
107
+ }
108
+
109
+ if (!candidates.includes('Inter')) {
110
+ candidates.push('Inter');
111
+ }
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
+ }
121
+ if (styles.includes('Regular')) {
122
+ return { family: candidate, style: 'Regular' };
123
+ }
124
+ }
125
+
126
+ return { family: 'Inter', style: 'Regular' };
127
+ }
128
+
129
+ function getFontFamilyStack(cssFamily) {
130
+ return String(cssFamily || '')
131
+ .split(',')
132
+ .map((part) => part.trim().replace(/['"]/g, ''))
133
+ .filter(Boolean);
134
+ }
135
+
136
+ function getGenericFontFamily(stack) {
137
+ if (!Array.isArray(stack) || stack.length === 0) {
138
+ return null;
139
+ }
140
+
141
+ const last = stack[stack.length - 1].toLowerCase();
142
+ if (last === 'serif' || last === 'sans-serif' || last === 'monospace') {
143
+ return last;
144
+ }
145
+
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
src/figma/mapper.js CHANGED
@@ -15,11 +15,12 @@ import {
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';
@@ -39,6 +40,15 @@ export function buildFigmaTree({ annotated }, { pseudoElements = [], gridStrateg
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;
@@ -53,14 +63,56 @@ function buildNode(node, parentContext, ctx, path) {
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
  };
@@ -83,7 +135,7 @@ function buildNode(node, parentContext, ctx, path) {
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
  } : {}),
@@ -100,7 +152,7 @@ function buildNode(node, parentContext, ctx, path) {
100
  typography.textTruncation = 'ENDING';
101
  }
102
 
103
- return {
104
  ...base,
105
  characters: text,
106
  ...typography,
@@ -109,6 +161,12 @@ function buildNode(node, parentContext, ctx, path) {
109
  textRuns: buildTextRuns(textRuns, ctx.fontMap),
110
  opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
111
  };
 
 
 
 
 
 
112
  }
113
 
114
  // Frame node
@@ -117,11 +175,38 @@ function buildNode(node, parentContext, ctx, path) {
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
  const flexAutoMarginLayout = isFlex ? getFlexAutoMarginLayoutOverride(node, layout) : null;
126
  const nativeControlLayout = getNativeControlLayout(node);
127
 
@@ -137,10 +222,10 @@ function buildNode(node, parentContext, ctx, path) {
137
  let fills = mapBackgroundColor(computed);
138
  const backgroundPattern = detectBackgroundPattern(computed);
139
 
140
- // Handle linear-gradient in backgroundImage
141
- if (!backgroundPattern && computed.backgroundImage && computed.backgroundImage.includes('linear-gradient')) {
142
  try {
143
- fills.push(parseLinearGradient(computed.backgroundImage));
144
  } catch { /* skip malformed gradients */ }
145
  }
146
 
@@ -159,7 +244,7 @@ function buildNode(node, parentContext, ctx, path) {
159
  ...mapOverflow(computed),
160
  ...mapBorderRadius(computed, rect),
161
  ...mapBorder(computed),
162
- effects: mapBoxShadow(computed),
163
  opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
164
  ...(layout || {}),
165
  ...(flexAutoMarginLayout || {}),
@@ -169,6 +254,15 @@ function buildNode(node, parentContext, ctx, path) {
169
  } : {}),
170
  };
171
 
 
 
 
 
 
 
 
 
 
172
  if (_pageLayout || tag === 'body') {
173
  frameNode.clipsContent = true;
174
  }
@@ -193,7 +287,11 @@ function buildNode(node, parentContext, ctx, path) {
193
  const childTextTruncationContext = usesTableCellAutoWidth ? null : getChildTextTruncationContext(node, resolvedRect, inheritedTextTruncationContext);
194
 
195
  if (isLeafText) {
196
- childNodes.push(buildEmbeddedTextNode(node, ctx, `${path}.text`, resolvedRect, 'text', childTextTruncationContext, usesTableCellAutoWidth));
 
 
 
 
197
  }
198
 
199
  const controlTextNode = buildFormControlTextNode(node, ctx, `${path}.control`, resolvedRect, childTextTruncationContext, usesTableCellAutoWidth);
@@ -216,11 +314,17 @@ function buildNode(node, parentContext, ctx, path) {
216
 
217
  const pseudoBefore = renderablePseudoChildren
218
  .filter((pseudo) => pseudo.zOrder !== 'top')
219
- .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudo.${index}`, ctx))
 
 
 
220
  .filter(Boolean);
221
  const pseudoTop = renderablePseudoChildren
222
  .filter((pseudo) => pseudo.zOrder === 'top')
223
- .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudoTop.${index}`, ctx))
 
 
 
224
  .filter(Boolean);
225
 
226
  const orderedChildren = getOrderedChildren(children);
@@ -234,6 +338,7 @@ function buildNode(node, parentContext, ctx, path) {
234
  textTruncationContext: childTextTruncationContext,
235
  tableCellAutoWidth: usesTableCellAutoWidth,
236
  surfaceFills: nextSurfaceFills,
 
237
  }, ctx, `${path}.${index}`),
238
  }))
239
  .filter((pair) => Boolean(pair.built));
@@ -253,6 +358,16 @@ function buildNode(node, parentContext, ctx, path) {
253
  function mapChildLayoutSizing(node, parentContext, resolvedRect) {
254
  const parentNode = parentContext?.sourceNode;
255
  const parentComputed = parentNode?.computed;
 
 
 
 
 
 
 
 
 
 
256
  if (!node || !resolvedRect || !parentContext?.resolvedRect || !isFlexDisplay(parentComputed?.display) || isAbsoluteLikeNode(node)) {
257
  return {};
258
  }
@@ -425,11 +540,16 @@ function getNativePseudoChildren(node) {
425
  return result;
426
  }
427
 
428
- function buildPseudoNode(pseudo, path, ctx = {}) {
429
  const pseudoId = `pseudo-${path}-${pseudo.name.replace(/\s+/g, '-').toLowerCase()}`;
430
- const isTextPseudo = pseudo.type === 'text' && Boolean(pseudo.content);
431
- const pseudoBackgrounds = isTextPseudo ? [] : buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor);
432
- const pseudoEffects = pseudo.computed ? mapBoxShadow(pseudo.computed) : [];
 
 
 
 
 
433
  const pseudoStrokes = pseudo.computed ? mapBorder(pseudo.computed) : {};
434
  const textTypography = pseudo.computed
435
  ? {
@@ -447,36 +567,65 @@ function buildPseudoNode(pseudo, path, ctx = {}) {
447
  : [colorSolidPaint('#ffffff')],
448
  };
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  return {
451
  id: pseudoId,
452
  name: `[pseudo] ${pseudo.name}`,
453
  type: 'FRAME',
454
  x: Math.round(pseudo.x),
455
  y: Math.round(pseudo.y),
456
- width: Math.round(pseudo.width),
457
- height: Math.round(pseudo.height),
458
- layoutPositioning: 'ABSOLUTE',
459
- opacity: roundFloat(pseudo.opacity ?? 1),
460
- fills: pseudoBackgrounds,
461
- ...(isTextPseudo ? { clipsContent: false } : {}),
462
- ...pseudoStrokes,
 
463
  effects: pseudoEffects,
464
  _isPseudo: true,
465
  _pseudoType: pseudo.type,
466
  _pseudoPosition: pseudo.position,
467
- children: pseudo.content ? [{
468
  id: `${pseudoId}-content`,
469
  name: 'content',
470
  type: 'TEXT',
471
- characters: pseudo.content,
472
  x: 0, y: 0,
473
- width: pseudo.width,
474
- height: pseudo.height,
475
  ...textTypography,
476
  }] : [],
477
  };
478
  }
479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  function buildFormControlTextNode(node, ctx, path, resolvedRect = null, textTruncationContext = null, tableCellAutoWidth = false) {
481
  const rendered = resolveFormControlText(node.formControl);
482
  if (!rendered) {
@@ -629,25 +778,29 @@ function mergeFormControlTextStyles(baseComputed, overrideComputed) {
629
  return merged;
630
  }
631
 
632
- function buildPseudoBackgrounds(computed, fallbackFillColor) {
633
- if (!computed) {
634
- return fallbackFillColor && fallbackFillColor !== 'noise-texture'
635
- ? [colorSolidPaint(fallbackFillColor)]
636
- : [];
 
 
 
 
637
  }
638
 
639
  const fills = mapBackgroundColor(computed);
640
- if (computed.backgroundImage && computed.backgroundImage.includes('linear-gradient')) {
641
- fills.push(...parseLinearGradientLayers(computed.backgroundImage));
642
- }
643
-
644
- // A pseudo box inherits `color` from its parent, but that text color is not a
645
- // background paint. Unsupported decorative backgrounds should stay transparent.
646
- return fills;
647
- }
648
 
649
  function buildMergedPseudoBackgrounds(pseudo) {
650
- const paints = buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor);
651
  const opacity = Number.isFinite(pseudo.opacity) ? pseudo.opacity : 1;
652
  return paints.map((paint) => applyPaintOpacity(paint, opacity));
653
  }
@@ -657,6 +810,10 @@ function shouldMergePseudoIntoParent(node, pseudo) {
657
  return false;
658
  }
659
 
 
 
 
 
660
  const position = pseudo.position;
661
  if (position !== 'absolute' && position !== 'fixed') {
662
  return false;
@@ -679,7 +836,7 @@ function shouldMergePseudoIntoParent(node, pseudo) {
679
  return false;
680
  }
681
 
682
- return buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor).length > 0;
683
  }
684
 
685
  function isTransparentCssBackground(computed) {
@@ -710,6 +867,13 @@ function clonePaints(paints) {
710
  return (paints || []).map((paint) => JSON.parse(JSON.stringify(paint)));
711
  }
712
 
 
 
 
 
 
 
 
713
  function isPaginationNode(node) {
714
  if (!node) {
715
  return false;
@@ -1712,28 +1876,267 @@ function mapFlexTextAxisAlignment(value, axisRole) {
1712
  }
1713
 
1714
  function detectBackgroundPattern(computed) {
 
 
 
 
1715
  const backgroundImage = computed.backgroundImage || '';
1716
  const backgroundSize = computed.backgroundSize || '';
1717
- if (!backgroundImage.includes('linear-gradient') || !backgroundSize.includes('px')) {
1718
  return null;
1719
  }
1720
 
1721
- const gradientCount = (backgroundImage.match(/linear-gradient\(/g) || []).length;
1722
- if (gradientCount < 2) {
 
 
 
 
 
 
 
 
1723
  return null;
1724
  }
1725
 
1726
- const sizeMatch = backgroundSize.match(/([\d.]+)px\s+([\d.]+)px/);
1727
- const colorMatch = backgroundImage.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/);
1728
- if (!sizeMatch || !colorMatch) {
 
 
 
 
 
 
1729
  return null;
1730
  }
1731
 
1732
  return {
1733
  kind: 'grid',
1734
- cellWidth: Math.max(Math.round(parseFloat(sizeMatch[1])), 1),
1735
- cellHeight: Math.max(Math.round(parseFloat(sizeMatch[2])), 1),
1736
- strokeWeight: 1,
1737
- paint: colorSolidPaint(colorMatch[0]),
 
 
1738
  };
1739
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  mapBackgroundColor,
16
  mapBorder,
17
  mapBoxShadow,
18
+ mapDropShadowFilter,
19
  mapTypography,
20
  mapTextStroke,
21
  shouldTruncateText,
22
+ parseGradientLayers,
23
+ splitCssLayers,
24
  } from './css-to-figma.js';
25
  import { cssColorToFigma, solidPaint as colorSolidPaint } from '../utils/color.js';
26
  import { parsePx } from '../utils/units.js';
 
40
 
41
  function buildNode(node, parentContext, ctx, path) {
42
  const { computed, rect, tag, text, textRuns = [], children = [], classList, isTextContainer, _pageLayout, _role, svgMarkup, imageData } = node;
43
+
44
+ let liIndex;
45
+ if (tag === 'li' && parentContext?.sourceNode?.children) {
46
+ const siblings = parentContext.sourceNode.children.filter(c => c.tag === 'li');
47
+ const idx = siblings.indexOf(node);
48
+ if (idx !== -1) {
49
+ liIndex = idx + 1;
50
+ }
51
+ }
52
  const rawResolvedRect = resolveRenderedRect(node, parentContext);
53
  const parentResolvedRect = parentContext?.resolvedRect ?? null;
54
  const isLeafText = Boolean(text) && children.length === 0;
 
63
  const isAbsolute = isAbsoluteLikeNode(node) || node._layoutPositioning === 'ABSOLUTE';
64
  const childLayoutSizing = mapChildLayoutSizing(node, parentContext, resolvedRect);
65
 
66
+ const transform = computed.transform || 'none';
67
+ let { rotation } = parseRotationAndScale(transform);
68
+
69
+ const writingMode = computed.writingMode || 'horizontal-tb';
70
+ const isVerticalWritingMode = writingMode.startsWith('vertical-');
71
+
72
+ let unrotatedWidth = resolvedRect.width;
73
+ let unrotatedHeight = resolvedRect.height;
74
+
75
+ if (isVerticalWritingMode && Math.abs(rotation) <= 0.01) {
76
+ rotation = -90;
77
+ unrotatedWidth = resolvedRect.offsetHeight || resolvedRect.height;
78
+ unrotatedHeight = resolvedRect.offsetWidth || resolvedRect.width;
79
+ } else if (Math.abs(rotation) > 0.01) {
80
+ if (resolvedRect.offsetWidth && resolvedRect.offsetHeight) {
81
+ unrotatedWidth = resolvedRect.offsetWidth;
82
+ unrotatedHeight = resolvedRect.offsetHeight;
83
+ } else if (Math.abs(Math.abs(rotation) - 90) < 5) {
84
+ unrotatedWidth = resolvedRect.height;
85
+ unrotatedHeight = resolvedRect.width;
86
+ }
87
+ }
88
+
89
+ let figmaX = resolvedRect.x - (parentResolvedRect?.x ?? 0);
90
+ let figmaY = resolvedRect.y - (parentResolvedRect?.y ?? 0);
91
+
92
+ if (Math.abs(rotation) > 0.01) {
93
+ const rad = (rotation * Math.PI) / 180;
94
+ const cosVal = Math.cos(rad);
95
+ const sinVal = Math.sin(rad);
96
+
97
+ const cx = resolvedRect.x + resolvedRect.width / 2;
98
+ const cy = resolvedRect.y + resolvedRect.height / 2;
99
+
100
+ const absoluteFigmaX = cx - (unrotatedWidth / 2) * cosVal + (unrotatedHeight / 2) * sinVal;
101
+ const absoluteFigmaY = cy - (unrotatedWidth / 2) * sinVal - (unrotatedHeight / 2) * cosVal;
102
+
103
+ figmaX = absoluteFigmaX - (parentResolvedRect?.x ?? 0);
104
+ figmaY = absoluteFigmaY - (parentResolvedRect?.y ?? 0);
105
+ }
106
+
107
  const base = {
108
  id: buildStableId(tag, classList, path),
109
  name: buildName(tag, classList),
110
  type: isSvg ? 'SVG' : isImage ? 'IMAGE' : (isText && text ? 'TEXT' : 'FRAME'),
111
+ x: Math.round(figmaX),
112
+ y: Math.round(figmaY),
113
+ width: Math.round(unrotatedWidth),
114
+ height: Math.round(unrotatedHeight),
115
+ ...(Math.abs(rotation) > 0.01 ? { rotation: roundFloat(rotation, 2) } : {}),
116
  ...(isAbsolute ? { layoutPositioning: 'ABSOLUTE' } : {}),
117
  ...childLayoutSizing,
118
  };
 
135
  opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
136
  ...mapBorderRadius(computed, rect),
137
  ...mapBorder(computed),
138
+ effects: mapVisualEffects(computed),
139
  ...(computed.mixBlendMode && computed.mixBlendMode !== 'normal' ? {
140
  blendMode: computed.mixBlendMode.toUpperCase().replace(/-/g, '_'),
141
  } : {}),
 
152
  typography.textTruncation = 'ENDING';
153
  }
154
 
155
+ const textNode = {
156
  ...base,
157
  characters: text,
158
  ...typography,
 
161
  textRuns: buildTextRuns(textRuns, ctx.fontMap),
162
  opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
163
  };
164
+
165
+ if (parentContext?.sourceNode?.tag === 'li' && parentContext?.parentLayout?.layoutMode === 'HORIZONTAL') {
166
+ textNode._forceAutoWidth = true;
167
+ }
168
+
169
+ return textNode;
170
  }
171
 
172
  // Frame node
 
175
  const isInlineBlock = computed.display === 'inline-block';
176
  const flexLayoutInfo = isFlex ? getRenderableFlexLayout(node) : null;
177
 
178
+ let layout = isFlex
179
  ? flexLayoutInfo?.layout
180
  : isInlineBlock
181
  ? getRenderableInlineLayout(node)
182
  : null;
183
+
184
+ if (!layout && tag === 'li' && (node.pseudo?.before || node.pseudoChildren?.some(p => p.name && (p.name === 'before' || p.name.endsWith('::before'))))) {
185
+ const children = Array.isArray(node.children) ? node.children.filter(Boolean) : [];
186
+ const hasOnlyInlineOrSimpleChildren = children.every(child =>
187
+ child.tag === 'span' ||
188
+ child.tag === 'a' ||
189
+ child.tag === 'code' ||
190
+ child.tag === 'strong' ||
191
+ child.tag === 'em' ||
192
+ child.tag === 'i' ||
193
+ child.tag === 'b' ||
194
+ child.tag === 'u' ||
195
+ child.tag === 'small' ||
196
+ child.tag === 'p' ||
197
+ child.isTextContainer ||
198
+ !child.tag
199
+ );
200
+
201
+ if (hasOnlyInlineOrSimpleChildren) {
202
+ layout = {
203
+ layoutMode: 'HORIZONTAL',
204
+ primaryAxisAlignItems: 'MIN',
205
+ counterAxisAlignItems: 'CENTER',
206
+ itemSpacing: 12,
207
+ };
208
+ }
209
+ }
210
  const flexAutoMarginLayout = isFlex ? getFlexAutoMarginLayoutOverride(node, layout) : null;
211
  const nativeControlLayout = getNativeControlLayout(node);
212
 
 
222
  let fills = mapBackgroundColor(computed);
223
  const backgroundPattern = detectBackgroundPattern(computed);
224
 
225
+ // Handle supported CSS gradient layers in backgroundImage.
226
+ if (!backgroundPattern && computed.backgroundImage) {
227
  try {
228
+ fills.push(...parseGradientLayers(computed.backgroundImage, rect));
229
  } catch { /* skip malformed gradients */ }
230
  }
231
 
 
244
  ...mapOverflow(computed),
245
  ...mapBorderRadius(computed, rect),
246
  ...mapBorder(computed),
247
+ effects: mapVisualEffects(computed),
248
  opacity: roundFloat(parseFloat(computed.opacity ?? 1)),
249
  ...(layout || {}),
250
  ...(flexAutoMarginLayout || {}),
 
254
  } : {}),
255
  };
256
 
257
+ if (layout && tag === 'li') {
258
+ const pseudoBefore = node.pseudo?.before || node.pseudoChildren?.find(p => p.name === 'before' || p.name?.endsWith('::before'));
259
+ const isAbsolutePseudo = pseudoBefore?.computed?.position === 'absolute';
260
+ if (isAbsolutePseudo) {
261
+ const originalPaddingLeft = frameNode.paddingLeft || 0;
262
+ frameNode.paddingLeft = Math.max(0, originalPaddingLeft - 24);
263
+ }
264
+ }
265
+
266
  if (_pageLayout || tag === 'body') {
267
  frameNode.clipsContent = true;
268
  }
 
287
  const childTextTruncationContext = usesTableCellAutoWidth ? null : getChildTextTruncationContext(node, resolvedRect, inheritedTextTruncationContext);
288
 
289
  if (isLeafText) {
290
+ const textNode = buildEmbeddedTextNode(node, ctx, `${path}.text`, resolvedRect, 'text', childTextTruncationContext, usesTableCellAutoWidth);
291
+ if (parentContext?.sourceNode?.tag === 'li' && parentContext?.parentLayout?.layoutMode === 'HORIZONTAL') {
292
+ textNode._forceAutoWidth = true;
293
+ }
294
+ childNodes.push(textNode);
295
  }
296
 
297
  const controlTextNode = buildFormControlTextNode(node, ctx, `${path}.control`, resolvedRect, childTextTruncationContext, usesTableCellAutoWidth);
 
314
 
315
  const pseudoBefore = renderablePseudoChildren
316
  .filter((pseudo) => pseudo.zOrder !== 'top')
317
+ .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudo.${index}`, ctx, {
318
+ participatesInLayout: shouldPseudoParticipateInParentLayout(node, pseudo, layout),
319
+ liIndex,
320
+ }))
321
  .filter(Boolean);
322
  const pseudoTop = renderablePseudoChildren
323
  .filter((pseudo) => pseudo.zOrder === 'top')
324
+ .map((pseudo, index) => buildPseudoNode(pseudo, `${path}.pseudoTop.${index}`, ctx, {
325
+ participatesInLayout: shouldPseudoParticipateInParentLayout(node, pseudo, layout),
326
+ liIndex,
327
+ }))
328
  .filter(Boolean);
329
 
330
  const orderedChildren = getOrderedChildren(children);
 
338
  textTruncationContext: childTextTruncationContext,
339
  tableCellAutoWidth: usesTableCellAutoWidth,
340
  surfaceFills: nextSurfaceFills,
341
+ parentLayout: layout,
342
  }, ctx, `${path}.${index}`),
343
  }))
344
  .filter((pair) => Boolean(pair.built));
 
358
  function mapChildLayoutSizing(node, parentContext, resolvedRect) {
359
  const parentNode = parentContext?.sourceNode;
360
  const parentComputed = parentNode?.computed;
361
+
362
+ if (parentNode?.tag === 'li' && parentContext?.parentLayout?.layoutMode === 'HORIZONTAL') {
363
+ const isPseudo = node._isPseudo || node.name === 'before' || node.name === 'after' || node.name?.startsWith('[pseudo]');
364
+ if (!isPseudo) {
365
+ return {
366
+ layoutSizingHorizontal: 'FILL',
367
+ };
368
+ }
369
+ }
370
+
371
  if (!node || !resolvedRect || !parentContext?.resolvedRect || !isFlexDisplay(parentComputed?.display) || isAbsoluteLikeNode(node)) {
372
  return {};
373
  }
 
540
  return result;
541
  }
542
 
543
+ function buildPseudoNode(pseudo, path, ctx = {}, options = {}) {
544
  const pseudoId = `pseudo-${path}-${pseudo.name.replace(/\s+/g, '-').toLowerCase()}`;
545
+ let pseudoContent = pseudo.content;
546
+ if (pseudoContent && pseudoContent.includes('counter(')) {
547
+ pseudoContent = resolveCounterText(pseudoContent, options.liIndex || 1);
548
+ }
549
+ const isTextPseudo = pseudo.type === 'text' && Boolean(pseudoContent);
550
+ const pseudoBackgrounds = isTextPseudo ? [] : buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor, pseudo);
551
+ const pseudoBackgroundPattern = isTextPseudo ? null : detectBackgroundPattern(pseudo.computed);
552
+ const pseudoEffects = pseudo.computed ? mapVisualEffects(pseudo.computed) : [];
553
  const pseudoStrokes = pseudo.computed ? mapBorder(pseudo.computed) : {};
554
  const textTypography = pseudo.computed
555
  ? {
 
567
  : [colorSolidPaint('#ffffff')],
568
  };
569
 
570
+ let pseudoWidth = pseudo.width;
571
+ let pseudoHeight = pseudo.height;
572
+ if (isTextPseudo && pseudoContent && options.liIndex) {
573
+ const fontSize = textTypography.fontSize || 16;
574
+ const estimatedWidth = Math.ceil(pseudoContent.length * fontSize * 0.65) + 4;
575
+ const estimatedHeight = Math.ceil(fontSize * 1.2);
576
+ if (pseudoWidth > estimatedWidth) {
577
+ pseudoWidth = estimatedWidth;
578
+ }
579
+ if (pseudoHeight > estimatedHeight) {
580
+ pseudoHeight = estimatedHeight;
581
+ }
582
+ }
583
+
584
  return {
585
  id: pseudoId,
586
  name: `[pseudo] ${pseudo.name}`,
587
  type: 'FRAME',
588
  x: Math.round(pseudo.x),
589
  y: Math.round(pseudo.y),
590
+ width: Math.round(pseudoWidth),
591
+ height: Math.round(pseudoHeight),
592
+ ...(!options.participatesInLayout ? { layoutPositioning: 'ABSOLUTE' } : {}),
593
+ opacity: roundFloat(pseudo.opacity ?? 1),
594
+ fills: pseudoBackgrounds,
595
+ ...(pseudoBackgroundPattern ? { _backgroundPattern: pseudoBackgroundPattern } : {}),
596
+ ...(isTextPseudo ? { clipsContent: false } : {}),
597
+ ...pseudoStrokes,
598
  effects: pseudoEffects,
599
  _isPseudo: true,
600
  _pseudoType: pseudo.type,
601
  _pseudoPosition: pseudo.position,
602
+ children: pseudoContent ? [{
603
  id: `${pseudoId}-content`,
604
  name: 'content',
605
  type: 'TEXT',
606
+ characters: pseudoContent,
607
  x: 0, y: 0,
608
+ width: pseudoWidth,
609
+ height: pseudoHeight,
610
  ...textTypography,
611
  }] : [],
612
  };
613
  }
614
 
615
+ function shouldPseudoParticipateInParentLayout(node, pseudo, layout = null) {
616
+ const hasAutoLayout = isFlexDisplay(node.computed?.display) || Boolean(layout);
617
+ if (!node || !pseudo || !hasAutoLayout) {
618
+ return false;
619
+ }
620
+
621
+ if (node.tag === 'li' && layout) {
622
+ return true;
623
+ }
624
+
625
+ const position = pseudo.position || pseudo.computed?.position || 'static';
626
+ return position !== 'absolute' && position !== 'fixed';
627
+ }
628
+
629
  function buildFormControlTextNode(node, ctx, path, resolvedRect = null, textTruncationContext = null, tableCellAutoWidth = false) {
630
  const rendered = resolveFormControlText(node.formControl);
631
  if (!rendered) {
 
778
  return merged;
779
  }
780
 
781
+ function buildPseudoBackgrounds(computed, fallbackFillColor, rect = null) {
782
+ if (!computed) {
783
+ return fallbackFillColor && fallbackFillColor !== 'noise-texture'
784
+ ? [colorSolidPaint(fallbackFillColor)]
785
+ : [];
786
+ }
787
+
788
+ if (detectBackgroundPattern(computed)) {
789
+ return mapBackgroundColor(computed);
790
  }
791
 
792
  const fills = mapBackgroundColor(computed);
793
+ if (computed.backgroundImage) {
794
+ fills.push(...parseGradientLayers(computed.backgroundImage, rect));
795
+ }
796
+
797
+ // A pseudo box inherits `color` from its parent, but that text color is not a
798
+ // background paint. Unsupported decorative backgrounds should stay transparent.
799
+ return fills;
800
+ }
801
 
802
  function buildMergedPseudoBackgrounds(pseudo) {
803
+ const paints = buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor, pseudo);
804
  const opacity = Number.isFinite(pseudo.opacity) ? pseudo.opacity : 1;
805
  return paints.map((paint) => applyPaintOpacity(paint, opacity));
806
  }
 
810
  return false;
811
  }
812
 
813
+ if (detectBackgroundPattern(pseudo.computed)) {
814
+ return false;
815
+ }
816
+
817
  const position = pseudo.position;
818
  if (position !== 'absolute' && position !== 'fixed') {
819
  return false;
 
836
  return false;
837
  }
838
 
839
+ return buildPseudoBackgrounds(pseudo.computed, pseudo.fillColor, pseudo).length > 0;
840
  }
841
 
842
  function isTransparentCssBackground(computed) {
 
867
  return (paints || []).map((paint) => JSON.parse(JSON.stringify(paint)));
868
  }
869
 
870
+ function mapVisualEffects(computed = {}) {
871
+ return [
872
+ ...mapBoxShadow(computed),
873
+ ...mapDropShadowFilter(computed),
874
+ ];
875
+ }
876
+
877
  function isPaginationNode(node) {
878
  if (!node) {
879
  return false;
 
1876
  }
1877
 
1878
  function detectBackgroundPattern(computed) {
1879
+ if (!computed) {
1880
+ return null;
1881
+ }
1882
+
1883
  const backgroundImage = computed.backgroundImage || '';
1884
  const backgroundSize = computed.backgroundSize || '';
1885
+ if (!backgroundImage.includes('linear-gradient')) {
1886
  return null;
1887
  }
1888
 
1889
+ const repeatingPattern = detectRepeatingLinearGridPattern(backgroundImage);
1890
+ if (repeatingPattern) {
1891
+ return repeatingPattern;
1892
+ }
1893
+
1894
+ return detectSizedLinearGridPattern(backgroundImage, backgroundSize);
1895
+ }
1896
+
1897
+ function detectSizedLinearGridPattern(backgroundImage, backgroundSize) {
1898
+ if (!String(backgroundSize || '').includes('px')) {
1899
  return null;
1900
  }
1901
 
1902
+ const layers = splitCssLayers(backgroundImage)
1903
+ .filter((layer) => /^linear-gradient\(/i.test(layer.trim()));
1904
+ if (layers.length < 2) {
1905
+ return null;
1906
+ }
1907
+
1908
+ const size = parseBackgroundGridSize(backgroundSize);
1909
+ const color = findVisibleCssColor(backgroundImage);
1910
+ if (!size || !color) {
1911
  return null;
1912
  }
1913
 
1914
  return {
1915
  kind: 'grid',
1916
+ cellWidth: size.width,
1917
+ cellHeight: size.height,
1918
+ strokeWeight: detectGridStrokeWeight(layers) || 1,
1919
+ paint: colorSolidPaint(color),
1920
+ verticalLines: true,
1921
+ horizontalLines: true,
1922
  };
1923
  }
1924
+
1925
+ function detectRepeatingLinearGridPattern(backgroundImage) {
1926
+ const layers = splitCssLayers(backgroundImage)
1927
+ .map((layer) => parseRepeatingLinearGridLayer(layer))
1928
+ .filter(Boolean);
1929
+
1930
+ if (layers.length === 0) {
1931
+ return null;
1932
+ }
1933
+
1934
+ const vertical = layers.find((layer) => layer.axis === 'x');
1935
+ const horizontal = layers.find((layer) => layer.axis === 'y');
1936
+ if (!vertical && !horizontal) {
1937
+ return null;
1938
+ }
1939
+
1940
+ const first = vertical || horizontal;
1941
+ return {
1942
+ kind: 'grid',
1943
+ cellWidth: Math.max(Math.round(vertical?.cellSize || horizontal?.cellSize || 1), 1),
1944
+ cellHeight: Math.max(Math.round(horizontal?.cellSize || vertical?.cellSize || 1), 1),
1945
+ strokeWeight: Math.max(Math.round(Math.min(
1946
+ vertical?.strokeWeight || horizontal?.strokeWeight || 1,
1947
+ horizontal?.strokeWeight || vertical?.strokeWeight || 1
1948
+ )), 1),
1949
+ paint: colorSolidPaint((vertical || horizontal).color),
1950
+ verticalLines: Boolean(vertical),
1951
+ horizontalLines: Boolean(horizontal),
1952
+ };
1953
+ }
1954
+
1955
+ function parseRepeatingLinearGridLayer(layer) {
1956
+ const source = String(layer || '').trim();
1957
+ if (!/^repeating-linear-gradient\(/i.test(source)) {
1958
+ return null;
1959
+ }
1960
+
1961
+ const color = findVisibleCssColor(source);
1962
+ const positions = extractPxPositions(source);
1963
+ if (!color || positions.length < 2) {
1964
+ return null;
1965
+ }
1966
+
1967
+ const axis = getLinearGradientAxis(source);
1968
+ const unique = Array.from(new Set(positions.map((value) => roundFloat(value, 3)))).sort((a, b) => a - b);
1969
+ const cellSize = Math.max(...unique);
1970
+ const strokeWeight = getSmallestPositiveGap(unique) || 1;
1971
+ if (!Number.isFinite(cellSize) || cellSize <= 0) {
1972
+ return null;
1973
+ }
1974
+
1975
+ return { axis, cellSize, strokeWeight, color };
1976
+ }
1977
+
1978
+ function parseBackgroundGridSize(backgroundSize) {
1979
+ const values = extractPxPositions(String(backgroundSize || '').split(',')[0] || '');
1980
+ if (values.length === 0) {
1981
+ return null;
1982
+ }
1983
+
1984
+ return {
1985
+ width: Math.max(Math.round(values[0]), 1),
1986
+ height: Math.max(Math.round(values[1] || values[0]), 1),
1987
+ };
1988
+ }
1989
+
1990
+ function detectGridStrokeWeight(layers) {
1991
+ const weights = [];
1992
+ for (const layer of layers) {
1993
+ const positions = Array.from(new Set(extractPxPositions(layer))).sort((a, b) => a - b);
1994
+ const gap = getSmallestPositiveGap(positions);
1995
+ if (gap) weights.push(gap);
1996
+ }
1997
+ return weights.length ? Math.max(Math.round(Math.min(...weights)), 1) : 1;
1998
+ }
1999
+
2000
+ function getSmallestPositiveGap(values) {
2001
+ let best = null;
2002
+ for (let index = 1; index < values.length; index++) {
2003
+ const gap = values[index] - values[index - 1];
2004
+ if (gap > 0 && (best === null || gap < best)) {
2005
+ best = gap;
2006
+ }
2007
+ }
2008
+ return best;
2009
+ }
2010
+
2011
+ function extractPxPositions(value) {
2012
+ return (String(value || '').match(/-?[\d.]+px/g) || [])
2013
+ .map((part) => parseFloat(part))
2014
+ .filter((number) => Number.isFinite(number));
2015
+ }
2016
+
2017
+ function findVisibleCssColor(value) {
2018
+ const matches = String(value || '').match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/g) || [];
2019
+ for (const match of matches) {
2020
+ if (cssColorToFigma(match).a > 0) {
2021
+ return match;
2022
+ }
2023
+ }
2024
+ return null;
2025
+ }
2026
+
2027
+ function getLinearGradientAxis(layer) {
2028
+ const lower = String(layer || '').toLowerCase();
2029
+ if (/repeating-linear-gradient\(\s*(90deg|270deg|to\s+(right|left))/.test(lower)) {
2030
+ return 'x';
2031
+ }
2032
+ return 'y';
2033
+ }
2034
+
2035
+ function toRoman(num, uppercase = true) {
2036
+ const romanLookup = [
2037
+ ['M', 1000],
2038
+ ['CM', 900],
2039
+ ['D', 500],
2040
+ ['CD', 400],
2041
+ ['C', 100],
2042
+ ['XC', 90],
2043
+ ['L', 50],
2044
+ ['XL', 40],
2045
+ ['X', 10],
2046
+ ['IX', 9],
2047
+ ['V', 5],
2048
+ ['IV', 4],
2049
+ ['I', 1]
2050
+ ];
2051
+ let res = '';
2052
+ let val = num;
2053
+ for (const [str, amount] of romanLookup) {
2054
+ while (val >= amount) {
2055
+ res += str;
2056
+ val -= amount;
2057
+ }
2058
+ }
2059
+ return uppercase ? res : res.toLowerCase();
2060
+ }
2061
+
2062
+ function toAlpha(num, uppercase = true) {
2063
+ let res = '';
2064
+ let val = num;
2065
+ while (val > 0) {
2066
+ const mod = (val - 1) % 26;
2067
+ res = String.fromCharCode((uppercase ? 65 : 97) + mod) + res;
2068
+ val = Math.floor((val - 1) / 26);
2069
+ }
2070
+ return res;
2071
+ }
2072
+
2073
+ function resolveCounterText(content, index) {
2074
+ if (!content) return '';
2075
+ const trimmed = content.trim();
2076
+
2077
+ // Format: counter(name, style)
2078
+ const counterMatch = trimmed.match(/counter\([^,]+,\s*([^)]+)\)/);
2079
+ if (counterMatch) {
2080
+ const style = counterMatch[1].trim().toLowerCase();
2081
+ if (style === 'decimal-leading-zero') {
2082
+ return String(index).padStart(2, '0');
2083
+ }
2084
+ if (style === 'upper-roman') {
2085
+ return toRoman(index, true);
2086
+ }
2087
+ if (style === 'lower-roman') {
2088
+ return toRoman(index, false);
2089
+ }
2090
+ if (style === 'lower-alpha' || style === 'lower-latin') {
2091
+ return toAlpha(index, false);
2092
+ }
2093
+ if (style === 'upper-alpha' || style === 'upper-latin') {
2094
+ return toAlpha(index, true);
2095
+ }
2096
+ return String(index);
2097
+ }
2098
+
2099
+ // Format: counter(name)
2100
+ if (trimmed.includes('counter(')) {
2101
+ return String(index);
2102
+ }
2103
+
2104
+ return content;
2105
+ }
2106
+
2107
+ function parseRotationAndScale(transform) {
2108
+ if (!transform || transform === 'none') {
2109
+ return { rotation: 0, scaleX: 1, scaleY: 1 };
2110
+ }
2111
+
2112
+ const matrixMatch = transform.match(/^matrix\(([^)]+)\)$/i);
2113
+ if (matrixMatch) {
2114
+ const values = matrixMatch[1].split(',').map((part) => parseFloat(part.trim()));
2115
+ if (values.length === 6 && values.every(Number.isFinite)) {
2116
+ const [a, b, c, d] = values;
2117
+ const angleRad = Math.atan2(b, a);
2118
+ const rotation = angleRad * (180 / Math.PI);
2119
+ const scaleX = Math.hypot(a, b);
2120
+ const scaleY = Math.hypot(c, d);
2121
+ return { rotation, scaleX, scaleY };
2122
+ }
2123
+ }
2124
+
2125
+ const matrix3dMatch = transform.match(/^matrix3d\(([^)]+)\)$/i);
2126
+ if (matrix3dMatch) {
2127
+ const values = matrix3dMatch[1].split(',').map((part) => parseFloat(part.trim()));
2128
+ if (values.length === 16 && values.every(Number.isFinite)) {
2129
+ const a = values[0];
2130
+ const b = values[1];
2131
+ const c = values[4];
2132
+ const d = values[5];
2133
+ const angleRad = Math.atan2(b, a);
2134
+ const rotation = angleRad * (180 / Math.PI);
2135
+ const scaleX = Math.hypot(a, b);
2136
+ const scaleY = Math.hypot(c, d);
2137
+ return { rotation, scaleX, scaleY };
2138
+ }
2139
+ }
2140
+
2141
+ return { rotation: 0, scaleX: 1, scaleY: 1 };
2142
+ }
src/figma/z-index-sorter.js CHANGED
@@ -1,84 +1,84 @@
1
- /**
2
- * src/figma/z-index-sorter.js
3
- * Converts CSS z-index stacking contexts into Figma layer order.
4
- *
5
- * Figma layer order: index 0 = bottom, index n-1 = top.
6
- * CSS z-index: higher = on top.
7
- *
8
- * Strategy:
9
- * 1. Flatten DOM into a list with effective z-index (inheriting from parent stacking context)
10
- * 2. Sort ascending
11
- * 3. Figma plugin inserts nodes in this order
12
- */
13
-
14
- import { walk } from '../core/dom-tree.js';
15
-
16
- /**
17
- * Returns the DOM tree annotated with effectiveZ values,
18
- * and the flat sorted list for layer insertion order.
19
- *
20
- * @param {object} domTree
21
- * @returns {{ annotated: object, sortedFlat: object[] }}
22
- */
23
- export function sortByZIndex(domTree) {
24
- const flat = [];
25
-
26
- // First pass: annotate each node with effectiveZ
27
- function annotate(node, parentZ = 0) {
28
- const z = parseZIndex(node.computed?.zIndex);
29
- const isStackingContext = createsStackingContext(node.computed);
30
- const effectiveZ = isStackingContext ? parentZ + z : parentZ;
31
-
32
- const annotated = { ...node, effectiveZ };
33
- flat.push(annotated);
34
-
35
- annotated.children = (node.children ?? []).map(child =>
36
- annotate(child, effectiveZ)
37
- );
38
-
39
- return annotated;
40
- }
41
-
42
- const annotated = annotate(domTree);
43
-
44
- // Sort flat list: lower effectiveZ = insert first (bottom layer in Figma)
45
- const sortedFlat = [...flat].sort((a, b) => a.effectiveZ - b.effectiveZ);
46
-
47
- return { annotated, sortedFlat };
48
- }
49
-
50
- function parseZIndex(value) {
51
- if (!value || value === 'auto') return 0;
52
- const n = parseInt(value);
53
- return isNaN(n) ? 0 : n;
54
- }
55
-
56
- /**
57
- * An element creates a stacking context if it has:
58
- * - position + z-index (not auto)
59
- * - opacity < 1
60
- * - transform (not none)
61
- * - filter
62
- * - will-change
63
- */
64
- function createsStackingContext(computed) {
65
- if (!computed) return false;
66
- const hasPosition = computed.position !== 'static';
67
- const hasZ = computed.zIndex !== 'auto';
68
- const hasOpacity = parseFloat(computed.opacity) < 1;
69
- const hasTransform = computed.transform !== 'none' && computed.transform !== '';
70
- return (hasPosition && hasZ) || hasOpacity || hasTransform;
71
- }
72
-
73
- /**
74
- * Get the VELA-specific z-index map for reference.
75
- * Used for validation / documentation purposes.
76
- */
77
- export const VELA_Z_REFERENCE = [
78
- { element: 'body background', z: 'auto' },
79
- { element: 'section.hero', z: 'auto' },
80
- { element: '.hero-right (decorative)', z: 'auto' },
81
- { element: '.hero-left (text content)', z: 2 },
82
- { element: 'nav (fixed)', z: 100 },
83
- { element: 'body::before (grain overlay)', z: 9999 },
84
- ];
 
1
+ /**
2
+ * src/figma/z-index-sorter.js
3
+ * Converts CSS z-index stacking contexts into Figma layer order.
4
+ *
5
+ * Figma layer order: index 0 = bottom, index n-1 = top.
6
+ * CSS z-index: higher = on top.
7
+ *
8
+ * Strategy:
9
+ * 1. Flatten DOM into a list with effective z-index (inheriting from parent stacking context)
10
+ * 2. Sort ascending
11
+ * 3. Figma plugin inserts nodes in this order
12
+ */
13
+
14
+ import { walk } from '../core/dom-tree.js';
15
+
16
+ /**
17
+ * Returns the DOM tree annotated with effectiveZ values,
18
+ * and the flat sorted list for layer insertion order.
19
+ *
20
+ * @param {object} domTree
21
+ * @returns {{ annotated: object, sortedFlat: object[] }}
22
+ */
23
+ export function sortByZIndex(domTree) {
24
+ const flat = [];
25
+
26
+ // First pass: annotate each node with effectiveZ
27
+ function annotate(node, parentZ = 0) {
28
+ const z = parseZIndex(node.computed?.zIndex);
29
+ const isStackingContext = createsStackingContext(node.computed);
30
+ const effectiveZ = isStackingContext ? parentZ + z : parentZ;
31
+
32
+ const annotated = { ...node, effectiveZ };
33
+ flat.push(annotated);
34
+
35
+ annotated.children = (node.children ?? []).map(child =>
36
+ annotate(child, effectiveZ)
37
+ );
38
+
39
+ return annotated;
40
+ }
41
+
42
+ const annotated = annotate(domTree);
43
+
44
+ // Sort flat list: lower effectiveZ = insert first (bottom layer in Figma)
45
+ const sortedFlat = [...flat].sort((a, b) => a.effectiveZ - b.effectiveZ);
46
+
47
+ return { annotated, sortedFlat };
48
+ }
49
+
50
+ function parseZIndex(value) {
51
+ if (!value || value === 'auto') return 0;
52
+ const n = parseInt(value);
53
+ return isNaN(n) ? 0 : n;
54
+ }
55
+
56
+ /**
57
+ * An element creates a stacking context if it has:
58
+ * - position + z-index (not auto)
59
+ * - opacity < 1
60
+ * - transform (not none)
61
+ * - filter
62
+ * - will-change
63
+ */
64
+ function createsStackingContext(computed) {
65
+ if (!computed) return false;
66
+ const hasPosition = computed.position !== 'static';
67
+ const hasZ = computed.zIndex !== 'auto';
68
+ const hasOpacity = parseFloat(computed.opacity) < 1;
69
+ const hasTransform = computed.transform !== 'none' && computed.transform !== '';
70
+ return (hasPosition && hasZ) || hasOpacity || hasTransform;
71
+ }
72
+
73
+ /**
74
+ * Get the VELA-specific z-index map for reference.
75
+ * Used for validation / documentation purposes.
76
+ */
77
+ export const VELA_Z_REFERENCE = [
78
+ { element: 'body background', z: 'auto' },
79
+ { element: 'section.hero', z: 'auto' },
80
+ { element: '.hero-right (decorative)', z: 'auto' },
81
+ { element: '.hero-left (text content)', z: 2 },
82
+ { element: 'nav (fixed)', z: 100 },
83
+ { element: 'body::before (grain overlay)', z: 9999 },
84
+ ];
src/pipeline/convert.js CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Shared Morphus conversion pipeline.
3
  * Reused by the CLI and the local HTTP bridge for the Figma plugin.
4
  */
5
 
@@ -44,38 +44,38 @@ export async function convertHtmlString(html, options = {}) {
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) {
75
- if (typeof onProgress === 'function') {
76
- onProgress(percent, message);
77
- }
78
- }
79
 
80
  function normalizeViewport(viewport = {}) {
81
  const width = Number.parseInt(viewport.width ?? DEFAULT_VIEWPORT.width, 10);
 
1
  /**
2
+ * Shared Morphus conversion pipeline.
3
  * Reused by the CLI and the local HTTP bridge for the Figma plugin.
4
  */
5
 
 
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) {
75
+ if (typeof onProgress === 'function') {
76
+ onProgress(percent, message);
77
+ }
78
+ }
79
 
80
  function normalizeViewport(viewport = {}) {
81
  const width = Number.parseInt(viewport.width ?? DEFAULT_VIEWPORT.width, 10);
src/utils/color.js CHANGED
@@ -1,68 +1,335 @@
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
+ const NAMED_COLORS = {
7
+ aliceblue: '#f0f8ff',
8
+ antiquewhite: '#faebd7',
9
+ aqua: '#00ffff',
10
+ aquamarine: '#7fffd4',
11
+ azure: '#f0ffff',
12
+ beige: '#f5f5dc',
13
+ bisque: '#ffe4c4',
14
+ black: '#000000',
15
+ blanchedalmond: '#ffebcd',
16
+ blue: '#0000ff',
17
+ blueviolet: '#8a2be2',
18
+ brown: '#a52a2a',
19
+ burlywood: '#deb887',
20
+ cadetblue: '#5f9ea0',
21
+ chartreuse: '#7fff00',
22
+ chocolate: '#d2691e',
23
+ coral: '#ff7f50',
24
+ cornflowerblue: '#6495ed',
25
+ cornsilk: '#fff8dc',
26
+ crimson: '#dc143c',
27
+ cyan: '#00ffff',
28
+ darkblue: '#00008b',
29
+ darkcyan: '#008b8b',
30
+ darkgoldenrod: '#b8860b',
31
+ darkgray: '#a9a9a9',
32
+ darkgreen: '#006400',
33
+ darkgrey: '#a9a9a9',
34
+ darkkhaki: '#bdb76b',
35
+ darkmagenta: '#8b008b',
36
+ darkolivegreen: '#556b2f',
37
+ darkorange: '#ff8c00',
38
+ darkorchid: '#9932cc',
39
+ darkred: '#8b0000',
40
+ darksalmon: '#e9967a',
41
+ darkseagreen: '#8fbc8f',
42
+ darkslate50: '#483d8b',
43
+ darkslateblue: '#483d8b',
44
+ darkslategray: '#2f4f4f',
45
+ darkslategrey: '#2f4f4f',
46
+ darkturquoise: '#00ced1',
47
+ darkviolet: '#9400d3',
48
+ deeppink: '#ff1493',
49
+ deepskyblue: '#00bfff',
50
+ dimgray: '#696969',
51
+ dimgrey: '#696969',
52
+ dodgerblue: '#1e90ff',
53
+ firebrick: '#b22222',
54
+ floralwhite: '#fffaf0',
55
+ forestgreen: '#228b22',
56
+ fuchsia: '#ff00ff',
57
+ gainsboro: '#dcdcdc',
58
+ ghostwhite: '#f8f8ff',
59
+ gold: '#ffd700',
60
+ goldenrod: '#daa520',
61
+ gray: '#808080',
62
+ green: '#008000',
63
+ greenyellow: '#adff2f',
64
+ grey: '#808080',
65
+ honeydew: '#f0fff0',
66
+ hotpink: '#ff69b4',
67
+ indianred: '#cd5c5c',
68
+ indigo: '#4b0082',
69
+ ivory: '#fffff0',
70
+ khaki: '#f0e68c',
71
+ lavender: '#e6e6fa',
72
+ lavenderblush: '#fff0f5',
73
+ lawngreen: '#7cfc00',
74
+ lemonchiffon: '#fffacd',
75
+ lightblue: '#add8e6',
76
+ lightcoral: '#f08080',
77
+ lightcyan: '#e0ffff',
78
+ lightgoldenrodyellow: '#fafad2',
79
+ lightgray: '#d3d3d3',
80
+ lightgreen: '#90ee90',
81
+ lightgrey: '#d3d3d3',
82
+ lightpink: '#ffb6c1',
83
+ lightsalmon: '#ffa07a',
84
+ lightseagreen: '#20b2aa',
85
+ lightskyblue: '#87cefa',
86
+ lightslategray: '#778899',
87
+ lightslategrey: '#778899',
88
+ lightsteelblue: '#b0c4de',
89
+ lightyellow: '#ffffe0',
90
+ lime: '#00ff00',
91
+ limegreen: '#32cd32',
92
+ linen: '#faf0e6',
93
+ magenta: '#ff00ff',
94
+ maroon: '#800000',
95
+ mediumaquamarine: '#66cdaa',
96
+ mediumblue: '#0000cd',
97
+ mediumorchid: '#ba55d3',
98
+ mediumpurple: '#9370db',
99
+ mediumseagreen: '#3cb371',
100
+ mediumslateblue: '#7b68ee',
101
+ mediumspringgreen: '#00fa9a',
102
+ mediumturquoise: '#48d1cc',
103
+ mediumvioletred: '#c71585',
104
+ midnightblue: '#191970',
105
+ mintcream: '#f5fffa',
106
+ mistyrose: '#ffe4e1',
107
+ moccasin: '#ffe4b5',
108
+ navajowhite: '#ffdead',
109
+ navy: '#000080',
110
+ oldlace: '#fdf5e6',
111
+ olive: '#808000',
112
+ olivedrab: '#6b8e23',
113
+ orange: '#ffa500',
114
+ orangered: '#ff4500',
115
+ orchid: '#da70d6',
116
+ palegoldenrod: '#eee8aa',
117
+ palegreen: '#98fb98',
118
+ paleturquoise: '#afeeee',
119
+ palevioletred: '#db7093',
120
+ papayawhip: '#ffefd5',
121
+ peachpuff: '#ffdab9',
122
+ peru: '#cd853f',
123
+ pink: '#ffc0cb',
124
+ plum: '#dda0dd',
125
+ powderblue: '#b0e0e6',
126
+ purple: '#800080',
127
+ rebeccapurple: '#663399',
128
+ red: '#ff0000',
129
+ rosybrown: '#bc8f8f',
130
+ royalblue: '#4169e1',
131
+ saddlebrown: '#8b4513',
132
+ salmon: '#fa8072',
133
+ sandybrown: '#f4a460',
134
+ seagreen: '#2e8b57',
135
+ seashell: '#fff5ee',
136
+ sienna: '#a0522d',
137
+ silver: '#c0c0c0',
138
+ skyblue: '#87ceeb',
139
+ slateblue: '#6a5acd',
140
+ slategray: '#708090',
141
+ slategrey: '#708090',
142
+ snow: '#fffafa',
143
+ springgreen: '#00ff7f',
144
+ steelblue: '#4682b4',
145
+ tan: '#d2b48c',
146
+ teal: '#008080',
147
+ thistle: '#d8bfd8',
148
+ tomato: '#ff6347',
149
+ turquoise: '#40e0d0',
150
+ violet: '#ee82ee',
151
+ wheat: '#f5deb3',
152
+ white: '#ffffff',
153
+ whitesmoke: '#f5f5f5',
154
+ yellow: '#ffff00',
155
+ yellowgreen: '#9acd32',
156
+ transparent: '#00000000',
157
+ };
158
+
159
+ /**
160
+ * Convert hex color to Figma RGBA object.
161
+ * Supports: #rgb, #rgba, #rrggbb, #rrggbbaa
162
+ * @param {string} hex - e.g. "#c9a84c" or "#fff"
163
+ * @returns {{ r: number, g: number, b: number, a: number }}
164
+ */
165
+ export function hexToFigmaRGB(hex) {
166
+ const clean = hex.replace('#', '');
167
+ if (clean.length === 3 || clean.length === 4) {
168
+ const r = parseInt(clean[0] + clean[0], 16) / 255;
169
+ const g = parseInt(clean[1] + clean[1], 16) / 255;
170
+ const b = parseInt(clean[2] + clean[2], 16) / 255;
171
+ const a = clean.length === 4 ? parseInt(clean[3] + clean[3], 16) / 255 : 1;
172
+ return { r, g, b, a };
173
+ }
174
+ const r = parseInt(clean.substring(0, 2), 16) / 255;
175
+ const g = parseInt(clean.substring(2, 4), 16) / 255;
176
+ const b = parseInt(clean.substring(4, 6), 16) / 255;
177
+ const a = clean.length === 8 ? parseInt(clean.substring(6, 8), 16) / 255 : 1;
178
+ return { r, g, b, a };
179
+ }
180
+
181
+ /**
182
+ * Convert CSS rgba() string to Figma RGBA.
183
+ * Supports standard commas syntax, modern spaces syntax, and percentages.
184
+ * @param {string} rgba - e.g. "rgba(201, 168, 76, 0.3)" or "rgba(200 255 0 / 0.5)"
185
+ * @returns {{ r: number, g: number, b: number, a: number }}
186
+ */
187
+ export function rgbaStringToFigma(rgba) {
188
+ const normalized = rgba.trim().toLowerCase();
189
+ const matches = normalized.match(/[\d.]+%?/g) || [];
190
+ if (matches.length < 3) {
191
+ return { r: 0, g: 0, b: 0, a: 1 };
192
+ }
193
+
194
+ const parseComponent = (val, max) => {
195
+ if (val.endsWith('%')) {
196
+ return (parseFloat(val) / 100) * max;
197
+ }
198
+ return parseFloat(val);
199
+ };
200
+
201
+ const rVal = parseComponent(matches[0], 255);
202
+ const gVal = parseComponent(matches[1], 255);
203
+ const bVal = parseComponent(matches[2], 255);
204
+
205
+ let aVal = 1;
206
+ if (matches.length >= 4) {
207
+ const aPart = matches[3];
208
+ if (aPart.endsWith('%')) {
209
+ aVal = parseFloat(aPart) / 100;
210
+ } else {
211
+ aVal = parseFloat(aPart);
212
+ }
213
+ }
214
+
215
+ return {
216
+ r: Math.max(0, Math.min(255, rVal)) / 255,
217
+ g: Math.max(0, Math.min(255, gVal)) / 255,
218
+ b: Math.max(0, Math.min(255, bVal)) / 255,
219
+ a: Math.max(0, Math.min(1, aVal))
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Convert HSL HSL/HSLA color values to RGB.
225
+ */
226
+ function hslToRgb(h, s, l) {
227
+ h = h % 360;
228
+ if (h < 0) h += 360;
229
+ s = Math.max(0, Math.min(100, s)) / 100;
230
+ l = Math.max(0, Math.min(100, l)) / 100;
231
+
232
+ const c = (1 - Math.abs(2 * l - 1)) * s;
233
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
234
+ const m = l - c / 2;
235
+
236
+ let r = 0, g = 0, b = 0;
237
+ if (h >= 0 && h < 60) {
238
+ r = c; g = x; b = 0;
239
+ } else if (h >= 60 && h < 120) {
240
+ r = x; g = c; b = 0;
241
+ } else if (h >= 120 && h < 180) {
242
+ r = 0; g = c; b = x;
243
+ } else if (h >= 180 && h < 240) {
244
+ r = 0; g = x; b = c;
245
+ } else if (h >= 240 && h < 300) {
246
+ r = x; g = 0; b = c;
247
+ } else if (h >= 300 && h < 360) {
248
+ r = c; g = 0; b = x;
249
+ }
250
+
251
+ return {
252
+ r: r + m,
253
+ g: g + m,
254
+ b: b + m
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Convert HSL/HSLA string to Figma RGBA.
260
+ */
261
+ export function hslStringToFigma(hslStr) {
262
+ const normalized = hslStr.trim().toLowerCase();
263
+ const matches = normalized.match(/[\d.]+(?:%|deg)?/g) || [];
264
+ if (matches.length < 3) {
265
+ return { r: 0, g: 0, b: 0, a: 1 };
266
+ }
267
+
268
+ const hVal = parseFloat(matches[0]);
269
+ const sVal = parseFloat(matches[1]);
270
+ const lVal = parseFloat(matches[2]);
271
+
272
+ let aVal = 1;
273
+ if (matches.length >= 4) {
274
+ const aPart = matches[3];
275
+ if (aPart.endsWith('%')) {
276
+ aVal = parseFloat(aPart) / 100;
277
+ } else {
278
+ aVal = parseFloat(aPart);
279
+ }
280
+ }
281
+
282
+ const rgb = hslToRgb(hVal, sVal, lVal);
283
+ return {
284
+ r: rgb.r,
285
+ g: rgb.g,
286
+ b: rgb.b,
287
+ a: Math.max(0, Math.min(1, aVal))
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Parse any CSS color string β†’ Figma RGBA.
293
+ * Handles: hex, rgba(), rgb(), hsl(), hsla(), named colors
294
+ */
295
+ export function cssColorToFigma(color) {
296
+ if (!color) {
297
+ return { r: 0, g: 0, b: 0, a: 0 };
298
+ }
299
+ const clean = color.trim().toLowerCase();
300
+ if (clean === 'transparent' || clean === 'none') {
301
+ return { r: 0, g: 0, b: 0, a: 0 };
302
+ }
303
+ if (clean.startsWith('#')) {
304
+ const parsed = hexToFigmaRGB(clean);
305
+ return { r: parsed.r, g: parsed.g, b: parsed.b, a: parsed.a ?? 1 };
306
+ }
307
+ if (clean.startsWith('rgb')) {
308
+ return rgbaStringToFigma(clean);
309
+ }
310
+ if (clean.startsWith('hsl')) {
311
+ return hslStringToFigma(clean);
312
+ }
313
+ if (NAMED_COLORS[clean]) {
314
+ const hex = NAMED_COLORS[clean];
315
+ if (hex === '#00000000') {
316
+ return { r: 0, g: 0, b: 0, a: 0 };
317
+ }
318
+ const parsed = hexToFigmaRGB(hex);
319
+ return { r: parsed.r, g: parsed.g, b: parsed.b, a: parsed.a ?? 1 };
320
+ }
321
+ // Fallback
322
+ return { r: 0, g: 0, b: 0, a: 1 };
323
+ }
324
+
325
+ /**
326
+ * Build a Figma solid paint object.
327
+ */
328
+ export function solidPaint(cssColor, opacity = 1) {
329
+ const { r, g, b, a } = cssColorToFigma(cssColor);
330
+ return {
331
+ type: 'SOLID',
332
+ color: { r, g, b },
333
+ opacity: opacity * a,
334
+ };
335
+ }
src/utils/units.js CHANGED
@@ -1,109 +1,109 @@
1
- /**
2
- * src/utils/units.js
3
- * Unit conversion helpers for CSS β†’ Figma (always px).
4
- * Since we use computed styles from Playwright, most values
5
- * are already resolved to px by the browser. These helpers
6
- * handle the remaining edge cases.
7
- */
8
-
9
- /**
10
- * Parse a CSS px string β†’ number.
11
- * e.g. "24px" β†’ 24, "0" β†’ 0
12
- */
13
- export function parsePx(value) {
14
- if (!value || value === 'none' || value === 'auto') return 0;
15
- const n = parseFloat(value);
16
- return isNaN(n) ? 0 : n;
17
- }
18
-
19
- /**
20
- * Convert CSS letter-spacing (em) to px, given a font size.
21
- * e.g. "0.12em", fontSize=13 β†’ 1.56
22
- */
23
- export function letterSpacingToPx(value, fontSize) {
24
- if (!value || value === 'normal') return 0;
25
- if (value.endsWith('em')) {
26
- return parseFloat(value) * parsePx(fontSize);
27
- }
28
- return parsePx(value);
29
- }
30
-
31
- /**
32
- * Convert CSS line-height to Figma format.
33
- * Returns { value: number, unit: "PERCENT" | "PIXELS" | "AUTO" }
34
- */
35
- export function lineHeightToFigma(value, fontSize) {
36
- if (!value || value === 'normal') return { unit: 'AUTO' };
37
-
38
- // Unitless (e.g. 1.8) β†’ multiply by 100 for percent
39
- const n = parseFloat(value);
40
- if (!isNaN(n) && !value.includes('px') && !value.includes('%')) {
41
- return { value: Math.round(n * 100), unit: 'PERCENT' };
42
- }
43
-
44
- if (value.endsWith('%')) {
45
- return { value: parseFloat(value), unit: 'PERCENT' };
46
- }
47
-
48
- if (value.endsWith('px')) {
49
- return { value: n, unit: 'PIXELS' };
50
- }
51
-
52
- return { unit: 'AUTO' };
53
- }
54
-
55
- /**
56
- * Convert CSS font-weight number β†’ Figma style name.
57
- */
58
- export const WEIGHT_MAP = {
59
- 100: 'Thin',
60
- 200: 'ExtraLight',
61
- 300: 'Light',
62
- 400: 'Regular',
63
- 500: 'Medium',
64
- 600: 'SemiBold',
65
- 700: 'Bold',
66
- 800: 'ExtraBold',
67
- 900: 'Black',
68
- };
69
-
70
- /**
71
- * Convert CSS text-align β†’ Figma textAlignHorizontal.
72
- */
73
- export const TEXT_ALIGN_MAP = {
74
- left: 'LEFT',
75
- center: 'CENTER',
76
- right: 'RIGHT',
77
- justify: 'JUSTIFIED',
78
- };
79
-
80
- /**
81
- * Convert CSS text-transform β†’ Figma textCase.
82
- */
83
- export const TEXT_CASE_MAP = {
84
- uppercase: 'UPPER',
85
- lowercase: 'LOWER',
86
- capitalize: 'TITLE',
87
- none: 'ORIGINAL',
88
- };
89
-
90
- /**
91
- * Convert CSS justify-content β†’ Figma primaryAxisAlignItems.
92
- */
93
- export const JUSTIFY_MAP = {
94
- 'flex-start': 'MIN',
95
- 'center': 'CENTER',
96
- 'flex-end': 'MAX',
97
- 'space-between': 'SPACE_BETWEEN',
98
- };
99
-
100
- /**
101
- * Convert CSS align-items β†’ Figma counterAxisAlignItems.
102
- */
103
- export const ALIGN_MAP = {
104
- 'flex-start': 'MIN',
105
- 'center': 'CENTER',
106
- 'flex-end': 'MAX',
107
- 'stretch': 'STRETCH',
108
- 'baseline': 'BASELINE',
109
- };
 
1
+ /**
2
+ * src/utils/units.js
3
+ * Unit conversion helpers for CSS β†’ Figma (always px).
4
+ * Since we use computed styles from Playwright, most values
5
+ * are already resolved to px by the browser. These helpers
6
+ * handle the remaining edge cases.
7
+ */
8
+
9
+ /**
10
+ * Parse a CSS px string β†’ number.
11
+ * e.g. "24px" β†’ 24, "0" β†’ 0
12
+ */
13
+ export function parsePx(value) {
14
+ if (!value || value === 'none' || value === 'auto') return 0;
15
+ const n = parseFloat(value);
16
+ return isNaN(n) ? 0 : n;
17
+ }
18
+
19
+ /**
20
+ * Convert CSS letter-spacing (em) to px, given a font size.
21
+ * e.g. "0.12em", fontSize=13 β†’ 1.56
22
+ */
23
+ export function letterSpacingToPx(value, fontSize) {
24
+ if (!value || value === 'normal') return 0;
25
+ if (value.endsWith('em')) {
26
+ return parseFloat(value) * parsePx(fontSize);
27
+ }
28
+ return parsePx(value);
29
+ }
30
+
31
+ /**
32
+ * Convert CSS line-height to Figma format.
33
+ * Returns { value: number, unit: "PERCENT" | "PIXELS" | "AUTO" }
34
+ */
35
+ export function lineHeightToFigma(value, fontSize) {
36
+ if (!value || value === 'normal') return { unit: 'AUTO' };
37
+
38
+ // Unitless (e.g. 1.8) β†’ multiply by 100 for percent
39
+ const n = parseFloat(value);
40
+ if (!isNaN(n) && !value.includes('px') && !value.includes('%')) {
41
+ return { value: Math.round(n * 100), unit: 'PERCENT' };
42
+ }
43
+
44
+ if (value.endsWith('%')) {
45
+ return { value: parseFloat(value), unit: 'PERCENT' };
46
+ }
47
+
48
+ if (value.endsWith('px')) {
49
+ return { value: n, unit: 'PIXELS' };
50
+ }
51
+
52
+ return { unit: 'AUTO' };
53
+ }
54
+
55
+ /**
56
+ * Convert CSS font-weight number β†’ Figma style name.
57
+ */
58
+ export const WEIGHT_MAP = {
59
+ 100: 'Thin',
60
+ 200: 'ExtraLight',
61
+ 300: 'Light',
62
+ 400: 'Regular',
63
+ 500: 'Medium',
64
+ 600: 'SemiBold',
65
+ 700: 'Bold',
66
+ 800: 'ExtraBold',
67
+ 900: 'Black',
68
+ };
69
+
70
+ /**
71
+ * Convert CSS text-align β†’ Figma textAlignHorizontal.
72
+ */
73
+ export const TEXT_ALIGN_MAP = {
74
+ left: 'LEFT',
75
+ center: 'CENTER',
76
+ right: 'RIGHT',
77
+ justify: 'JUSTIFIED',
78
+ };
79
+
80
+ /**
81
+ * Convert CSS text-transform β†’ Figma textCase.
82
+ */
83
+ export const TEXT_CASE_MAP = {
84
+ uppercase: 'UPPER',
85
+ lowercase: 'LOWER',
86
+ capitalize: 'TITLE',
87
+ none: 'ORIGINAL',
88
+ };
89
+
90
+ /**
91
+ * Convert CSS justify-content β†’ Figma primaryAxisAlignItems.
92
+ */
93
+ export const JUSTIFY_MAP = {
94
+ 'flex-start': 'MIN',
95
+ 'center': 'CENTER',
96
+ 'flex-end': 'MAX',
97
+ 'space-between': 'SPACE_BETWEEN',
98
+ };
99
+
100
+ /**
101
+ * Convert CSS align-items οΏ½οΏ½οΏ½ Figma counterAxisAlignItems.
102
+ */
103
+ export const ALIGN_MAP = {
104
+ 'flex-start': 'MIN',
105
+ 'center': 'CENTER',
106
+ 'flex-end': 'MAX',
107
+ 'stretch': 'STRETCH',
108
+ 'baseline': 'BASELINE',
109
+ };