Spaces:
Sleeping
Sleeping
Upload 16 files
Browse files- Dockerfile +18 -18
- README.md +49 -49
- package-lock.json +0 -0
- package.json +22 -22
- scripts/convert.js +45 -45
- scripts/server.js +173 -173
- scripts/update-snapshot.js +20 -20
- src/core/dom-tree.js +43 -43
- src/core/extractor.js +918 -866
- src/figma/css-to-figma.js +699 -209
- src/figma/font-resolver.js +141 -141
- src/figma/mapper.js +459 -56
- src/figma/z-index-sorter.js +84 -84
- src/pipeline/convert.js +27 -27
- src/utils/color.js +335 -68
- src/utils/units.js +109 -109
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/
|
| 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/
|
| 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/
|
|
|
|
| 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/
|
| 11 |
-
const INPUT_PATH = './tests/
|
| 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 === '
|
| 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 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
const
|
| 469 |
-
|
| 470 |
-
&& !
|
| 471 |
-
&& !hasRenderablePseudo(
|
|
|
|
| 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: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1604 |
-
|
| 1605 |
-
|
| 1606 |
-
|
| 1607 |
-
|
| 1608 |
-
|
| 1609 |
-
|
| 1610 |
-
|
| 1611 |
-
|
| 1612 |
-
|
| 1613 |
-
|
| 1614 |
-
|
| 1615 |
-
|
| 1616 |
-
|
| 1617 |
-
|
| 1618 |
-
|
| 1619 |
-
|
| 1620 |
-
|
| 1621 |
-
|
| 1622 |
-
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
-
|
| 1630 |
-
|
| 1631 |
-
|
| 1632 |
-
|
| 1633 |
-
|
| 1634 |
-
|
| 1635 |
-
|
| 1636 |
-
|
| 1637 |
-
|
| 1638 |
-
|
| 1639 |
-
|
| 1640 |
-
|
| 1641 |
-
|
| 1642 |
-
|
| 1643 |
-
|
| 1644 |
-
|
| 1645 |
-
|
| 1646 |
-
|
| 1647 |
-
|
| 1648 |
-
|
| 1649 |
-
|
| 1650 |
-
|
| 1651 |
-
|
| 1652 |
-
|
| 1653 |
-
|
| 1654 |
-
|
| 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 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
const
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
|
| 432 |
-
return
|
| 433 |
type: 'DROP_SHADOW',
|
| 434 |
-
color:
|
| 435 |
-
offset: { x: parsePx(
|
| 436 |
-
radius: parsePx(
|
| 437 |
-
spread: parsePx(
|
| 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 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
const
|
| 484 |
-
const
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
if (normalized.includes('
|
| 515 |
-
if (normalized.includes('
|
| 516 |
-
return
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
}
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
}
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
|
|
|
| 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 |
-
|
| 22 |
-
|
| 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(
|
| 61 |
-
y: Math.round(
|
| 62 |
-
width: Math.round(
|
| 63 |
-
height: Math.round(
|
|
|
|
| 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:
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 141 |
-
if (!backgroundPattern && computed.backgroundImage
|
| 142 |
try {
|
| 143 |
-
fills.push(
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 431 |
-
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 457 |
-
height: Math.round(
|
| 458 |
-
layoutPositioning: 'ABSOLUTE',
|
| 459 |
-
opacity: roundFloat(pseudo.opacity ?? 1),
|
| 460 |
-
fills: pseudoBackgrounds,
|
| 461 |
-
...(
|
| 462 |
-
...
|
|
|
|
| 463 |
effects: pseudoEffects,
|
| 464 |
_isPseudo: true,
|
| 465 |
_pseudoType: pseudo.type,
|
| 466 |
_pseudoPosition: pseudo.position,
|
| 467 |
-
children:
|
| 468 |
id: `${pseudoId}-content`,
|
| 469 |
name: 'content',
|
| 470 |
type: 'TEXT',
|
| 471 |
-
characters:
|
| 472 |
x: 0, y: 0,
|
| 473 |
-
width:
|
| 474 |
-
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
|
| 641 |
-
fills.push(...
|
| 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')
|
| 1718 |
return null;
|
| 1719 |
}
|
| 1720 |
|
| 1721 |
-
const
|
| 1722 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1723 |
return null;
|
| 1724 |
}
|
| 1725 |
|
| 1726 |
-
const
|
| 1727 |
-
|
| 1728 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1729 |
return null;
|
| 1730 |
}
|
| 1731 |
|
| 1732 |
return {
|
| 1733 |
kind: 'grid',
|
| 1734 |
-
cellWidth:
|
| 1735 |
-
cellHeight:
|
| 1736 |
-
strokeWeight: 1,
|
| 1737 |
-
paint: colorSolidPaint(
|
|
|
|
|
|
|
| 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 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 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
|
| 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 |
+
};
|