Spaces:
Running
Running
Rewrite as Vite + React app with structural SVG color grouping
Browse files- Replace CRA with Vite
- Read <g fill> structure from Vectorizer.ai SVG output
- CIEDE2000 nearest-anchor color assignment
- SVG layer filtering and PSD export (ag-psd)
- Error message when SVG lacks group structure
- .gitattributes +0 -35
- .gitignore +21 -20
- README.md +1 -72
- eslint.config.js +29 -0
- index.html +13 -0
- package.json +22 -32
- public/favicon.ico +0 -0
- public/index.html +0 -43
- public/logo192.png +0 -0
- public/logo512.png +0 -0
- public/manifest.json +0 -25
- public/robots.txt +0 -3
- public/vite.svg +1 -0
- src/App.css +0 -38
- src/App.js +0 -25
- src/App.jsx +229 -0
- src/App.test.js +0 -8
- src/colorUtils.js +148 -0
- src/grouping.js +120 -0
- src/index.css +52 -13
- src/index.js +0 -17
- src/logo.svg +0 -1
- src/main.jsx +10 -0
- src/psdExport.js +86 -0
- src/reportWebVitals.js +0 -13
- src/setupTests.js +0 -5
- src/svgFilter.js +54 -0
- vite.config.js +7 -0
.gitattributes
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
CHANGED
|
@@ -1,23 +1,24 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
/node_modules
|
| 5 |
-
/.pnp
|
| 6 |
-
.pnp.js
|
| 7 |
-
|
| 8 |
-
# testing
|
| 9 |
-
/coverage
|
| 10 |
-
|
| 11 |
-
# production
|
| 12 |
-
/build
|
| 13 |
-
|
| 14 |
-
# misc
|
| 15 |
-
.DS_Store
|
| 16 |
-
.env.local
|
| 17 |
-
.env.development.local
|
| 18 |
-
.env.test.local
|
| 19 |
-
.env.production.local
|
| 20 |
-
|
| 21 |
npm-debug.log*
|
| 22 |
yarn-debug.log*
|
| 23 |
yarn-error.log*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
npm-debug.log*
|
| 5 |
yarn-debug.log*
|
| 6 |
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
README.md
CHANGED
|
@@ -6,77 +6,6 @@ colorTo: red
|
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
app_build_command: npm run build
|
| 9 |
-
app_file:
|
| 10 |
short_description: Vectorizer.AI to PSD
|
| 11 |
---
|
| 12 |
-
|
| 13 |
-
# Getting Started with Create React App
|
| 14 |
-
|
| 15 |
-
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
| 16 |
-
|
| 17 |
-
## Available Scripts
|
| 18 |
-
|
| 19 |
-
In the project directory, you can run:
|
| 20 |
-
|
| 21 |
-
### `npm start`
|
| 22 |
-
|
| 23 |
-
Runs the app in the development mode.\
|
| 24 |
-
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
| 25 |
-
|
| 26 |
-
The page will reload when you make changes.\
|
| 27 |
-
You may also see any lint errors in the console.
|
| 28 |
-
|
| 29 |
-
### `npm test`
|
| 30 |
-
|
| 31 |
-
Launches the test runner in the interactive watch mode.\
|
| 32 |
-
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
| 33 |
-
|
| 34 |
-
### `npm run build`
|
| 35 |
-
|
| 36 |
-
Builds the app for production to the `build` folder.\
|
| 37 |
-
It correctly bundles React in production mode and optimizes the build for the best performance.
|
| 38 |
-
|
| 39 |
-
The build is minified and the filenames include the hashes.\
|
| 40 |
-
Your app is ready to be deployed!
|
| 41 |
-
|
| 42 |
-
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
| 43 |
-
|
| 44 |
-
### `npm run eject`
|
| 45 |
-
|
| 46 |
-
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
| 47 |
-
|
| 48 |
-
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
| 49 |
-
|
| 50 |
-
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
| 51 |
-
|
| 52 |
-
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
| 53 |
-
|
| 54 |
-
## Learn More
|
| 55 |
-
|
| 56 |
-
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
| 57 |
-
|
| 58 |
-
To learn React, check out the [React documentation](https://reactjs.org/).
|
| 59 |
-
|
| 60 |
-
### Code Splitting
|
| 61 |
-
|
| 62 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
| 63 |
-
|
| 64 |
-
### Analyzing the Bundle Size
|
| 65 |
-
|
| 66 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
| 67 |
-
|
| 68 |
-
### Making a Progressive Web App
|
| 69 |
-
|
| 70 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
| 71 |
-
|
| 72 |
-
### Advanced Configuration
|
| 73 |
-
|
| 74 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
| 75 |
-
|
| 76 |
-
### Deployment
|
| 77 |
-
|
| 78 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
| 79 |
-
|
| 80 |
-
### `npm run build` fails to minify
|
| 81 |
-
|
| 82 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
|
|
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
app_build_command: npm run build
|
| 9 |
+
app_file: dist/index.html
|
| 10 |
short_description: Vectorizer.AI to PSD
|
| 11 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>svg-color-grouper</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
package.json
CHANGED
|
@@ -1,39 +1,29 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
-
"
|
| 6 |
-
|
| 7 |
-
"@testing-library/jest-dom": "^6.6.3",
|
| 8 |
-
"@testing-library/react": "^16.3.0",
|
| 9 |
-
"@testing-library/user-event": "^13.5.0",
|
| 10 |
-
"react": "^19.1.0",
|
| 11 |
-
"react-dom": "^19.1.0",
|
| 12 |
-
"react-scripts": "5.0.1",
|
| 13 |
-
"web-vitals": "^2.1.4"
|
| 14 |
-
},
|
| 15 |
"scripts": {
|
| 16 |
-
"
|
| 17 |
-
"build": "
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
},
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
]
|
| 26 |
},
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
"
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
}
|
| 39 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "svg-color-grouper",
|
|
|
|
| 3 |
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"ag-psd": "^30.1.0",
|
| 14 |
+
"react": "^19.2.0",
|
| 15 |
+
"react-dom": "^19.2.0"
|
|
|
|
| 16 |
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@eslint/js": "^9.39.1",
|
| 19 |
+
"@types/react": "^19.2.7",
|
| 20 |
+
"@types/react-dom": "^19.2.3",
|
| 21 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 22 |
+
"eslint": "^9.39.1",
|
| 23 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 24 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 25 |
+
"globals": "^16.5.0",
|
| 26 |
+
"jsdom": "^28.1.0",
|
| 27 |
+
"vite": "^7.3.1"
|
| 28 |
}
|
| 29 |
}
|
public/favicon.ico
DELETED
|
Binary file (3.87 kB)
|
|
|
public/index.html
DELETED
|
@@ -1,43 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8" />
|
| 5 |
-
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
| 6 |
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
-
<meta name="theme-color" content="#000000" />
|
| 8 |
-
<meta
|
| 9 |
-
name="description"
|
| 10 |
-
content="Web site created using create-react-app"
|
| 11 |
-
/>
|
| 12 |
-
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
| 13 |
-
<!--
|
| 14 |
-
manifest.json provides metadata used when your web app is installed on a
|
| 15 |
-
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
| 16 |
-
-->
|
| 17 |
-
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
| 18 |
-
<!--
|
| 19 |
-
Notice the use of %PUBLIC_URL% in the tags above.
|
| 20 |
-
It will be replaced with the URL of the `public` folder during the build.
|
| 21 |
-
Only files inside the `public` folder can be referenced from the HTML.
|
| 22 |
-
|
| 23 |
-
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
| 24 |
-
work correctly both with client-side routing and a non-root public URL.
|
| 25 |
-
Learn how to configure a non-root public URL by running `npm run build`.
|
| 26 |
-
-->
|
| 27 |
-
<title>React App</title>
|
| 28 |
-
</head>
|
| 29 |
-
<body>
|
| 30 |
-
<noscript>You need to enable JavaScript to run this app.</noscript>
|
| 31 |
-
<div id="root"></div>
|
| 32 |
-
<!--
|
| 33 |
-
This HTML file is a template.
|
| 34 |
-
If you open it directly in the browser, you will see an empty page.
|
| 35 |
-
|
| 36 |
-
You can add webfonts, meta tags, or analytics to this file.
|
| 37 |
-
The build step will place the bundled scripts into the <body> tag.
|
| 38 |
-
|
| 39 |
-
To begin the development, run `npm start` or `yarn start`.
|
| 40 |
-
To create a production bundle, use `npm run build` or `yarn build`.
|
| 41 |
-
-->
|
| 42 |
-
</body>
|
| 43 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public/logo192.png
DELETED
|
Binary file (5.35 kB)
|
|
|
public/logo512.png
DELETED
|
Binary file (9.66 kB)
|
|
|
public/manifest.json
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"short_name": "React App",
|
| 3 |
-
"name": "Create React App Sample",
|
| 4 |
-
"icons": [
|
| 5 |
-
{
|
| 6 |
-
"src": "favicon.ico",
|
| 7 |
-
"sizes": "64x64 32x32 24x24 16x16",
|
| 8 |
-
"type": "image/x-icon"
|
| 9 |
-
},
|
| 10 |
-
{
|
| 11 |
-
"src": "logo192.png",
|
| 12 |
-
"type": "image/png",
|
| 13 |
-
"sizes": "192x192"
|
| 14 |
-
},
|
| 15 |
-
{
|
| 16 |
-
"src": "logo512.png",
|
| 17 |
-
"type": "image/png",
|
| 18 |
-
"sizes": "512x512"
|
| 19 |
-
}
|
| 20 |
-
],
|
| 21 |
-
"start_url": ".",
|
| 22 |
-
"display": "standalone",
|
| 23 |
-
"theme_color": "#000000",
|
| 24 |
-
"background_color": "#ffffff"
|
| 25 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public/robots.txt
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
# https://www.robotstxt.org/robotstxt.html
|
| 2 |
-
User-agent: *
|
| 3 |
-
Disallow:
|
|
|
|
|
|
|
|
|
|
|
|
public/vite.svg
ADDED
|
|
src/App.css
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
.App {
|
| 2 |
-
text-align: center;
|
| 3 |
-
}
|
| 4 |
-
|
| 5 |
-
.App-logo {
|
| 6 |
-
height: 40vmin;
|
| 7 |
-
pointer-events: none;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
@media (prefers-reduced-motion: no-preference) {
|
| 11 |
-
.App-logo {
|
| 12 |
-
animation: App-logo-spin infinite 20s linear;
|
| 13 |
-
}
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
.App-header {
|
| 17 |
-
background-color: #282c34;
|
| 18 |
-
min-height: 100vh;
|
| 19 |
-
display: flex;
|
| 20 |
-
flex-direction: column;
|
| 21 |
-
align-items: center;
|
| 22 |
-
justify-content: center;
|
| 23 |
-
font-size: calc(10px + 2vmin);
|
| 24 |
-
color: white;
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
.App-link {
|
| 28 |
-
color: #61dafb;
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
@keyframes App-logo-spin {
|
| 32 |
-
from {
|
| 33 |
-
transform: rotate(0deg);
|
| 34 |
-
}
|
| 35 |
-
to {
|
| 36 |
-
transform: rotate(360deg);
|
| 37 |
-
}
|
| 38 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/App.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
import logo from './logo.svg';
|
| 2 |
-
import './App.css';
|
| 3 |
-
|
| 4 |
-
function App() {
|
| 5 |
-
return (
|
| 6 |
-
<div className="App">
|
| 7 |
-
<header className="App-header">
|
| 8 |
-
<img src={logo} className="App-logo" alt="logo" />
|
| 9 |
-
<p>
|
| 10 |
-
Edit <code>src/App.js</code> and save to reload.
|
| 11 |
-
</p>
|
| 12 |
-
<a
|
| 13 |
-
className="App-link"
|
| 14 |
-
href="https://reactjs.org"
|
| 15 |
-
target="_blank"
|
| 16 |
-
rel="noopener noreferrer"
|
| 17 |
-
>
|
| 18 |
-
Learn React
|
| 19 |
-
</a>
|
| 20 |
-
</header>
|
| 21 |
-
</div>
|
| 22 |
-
);
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
export default App;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/App.jsx
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback, useMemo, useEffect } from 'react';
|
| 2 |
+
import { extractColorsFromSVG, extractStructuralGroups, rgbToLab } from './colorUtils';
|
| 3 |
+
import { buildFilteredSvgHtml } from './svgFilter';
|
| 4 |
+
import { exportPSD } from './psdExport';
|
| 5 |
+
import { buildStructuralColorGroups } from './grouping';
|
| 6 |
+
|
| 7 |
+
function LoadingOverlay({ text, progress, pct }) {
|
| 8 |
+
return (
|
| 9 |
+
<div className="loading-overlay" style={{ opacity: 1, pointerEvents: 'all' }}>
|
| 10 |
+
<div className="spinner" />
|
| 11 |
+
<div className="loading-text">{text}</div>
|
| 12 |
+
<div className="loading-progress">{progress}</div>
|
| 13 |
+
<div className="progress-bar-wrap">
|
| 14 |
+
<div className="progress-bar-fill" style={{ width: `${pct}%` }} />
|
| 15 |
+
</div>
|
| 16 |
+
</div>
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function SwatchGroup({ group, colors, index, active, onClick }) {
|
| 21 |
+
return (
|
| 22 |
+
<div className={`swatch-group${active ? ' swatch-group-active' : ''}`} onClick={onClick}>
|
| 23 |
+
<div className="group-label">Group {index + 1} ({group.length}色)</div>
|
| 24 |
+
{group.map((ci) => (
|
| 25 |
+
<div key={ci} className="swatch" style={{ backgroundColor: colors[ci].hex }} title={colors[ci].hex} />
|
| 26 |
+
))}
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export default function App() {
|
| 32 |
+
const [svgDoc, setSvgDoc] = useState(null);
|
| 33 |
+
const [uniqueColors, setUniqueColors] = useState([]);
|
| 34 |
+
const [loading, setLoading] = useState(null);
|
| 35 |
+
const [exportStatus, setExportStatus] = useState('');
|
| 36 |
+
const [dragActive, setDragActive] = useState(false);
|
| 37 |
+
const [previewHtml, setPreviewHtml] = useState('');
|
| 38 |
+
const [visibleLayers, setVisibleLayers] = useState(null);
|
| 39 |
+
const [structuralGroups, setStructuralGroups] = useState(null);
|
| 40 |
+
const [loadError, setLoadError] = useState('');
|
| 41 |
+
|
| 42 |
+
const groups = useMemo(() => {
|
| 43 |
+
if (!structuralGroups) return [];
|
| 44 |
+
const avgL = (group) => {
|
| 45 |
+
let sum = 0;
|
| 46 |
+
for (const ci of group) {
|
| 47 |
+
const c = uniqueColors[ci];
|
| 48 |
+
sum += rgbToLab(c.r, c.g, c.b)[0];
|
| 49 |
+
}
|
| 50 |
+
return sum / group.length;
|
| 51 |
+
};
|
| 52 |
+
const g = [...structuralGroups];
|
| 53 |
+
g.sort((a, b) => avgL(b) - avgL(a));
|
| 54 |
+
return g;
|
| 55 |
+
}, [structuralGroups, uniqueColors]);
|
| 56 |
+
|
| 57 |
+
// Reset visibility when groups change
|
| 58 |
+
useEffect(() => { setVisibleLayers(null); }, [groups]);
|
| 59 |
+
|
| 60 |
+
const compositePreview = useMemo(() => {
|
| 61 |
+
if (!svgDoc || groups.length === 0) return previewHtml;
|
| 62 |
+
if (visibleLayers === null) return previewHtml;
|
| 63 |
+
if (visibleLayers.size === 0) return '';
|
| 64 |
+
const mergedColors = new Set();
|
| 65 |
+
for (const gi of visibleLayers) {
|
| 66 |
+
for (const ci of groups[gi]) {
|
| 67 |
+
mergedColors.add(uniqueColors[ci].hex);
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
return buildFilteredSvgHtml(svgDoc, mergedColors);
|
| 71 |
+
}, [svgDoc, groups, uniqueColors, visibleLayers, previewHtml]);
|
| 72 |
+
|
| 73 |
+
const loadSVG = useCallback(async (text) => {
|
| 74 |
+
setVisibleLayers(null);
|
| 75 |
+
setStructuralGroups(null);
|
| 76 |
+
setLoadError('');
|
| 77 |
+
setLoading({ text: 'SVGを解析中...', progress: '', pct: 0 });
|
| 78 |
+
await tick();
|
| 79 |
+
|
| 80 |
+
const parser = new DOMParser();
|
| 81 |
+
const doc = parser.parseFromString(text, 'image/svg+xml');
|
| 82 |
+
const svgEl = doc.querySelector('svg');
|
| 83 |
+
if (!svgEl) { setLoading(null); return; }
|
| 84 |
+
|
| 85 |
+
setSvgDoc(doc);
|
| 86 |
+
setPreviewHtml(new XMLSerializer().serializeToString(svgEl));
|
| 87 |
+
|
| 88 |
+
setLoading({ text: '色を抽出中...', progress: '', pct: 30 });
|
| 89 |
+
await tick();
|
| 90 |
+
const colors = extractColorsFromSVG(svgEl);
|
| 91 |
+
setUniqueColors(colors);
|
| 92 |
+
|
| 93 |
+
const structure = extractStructuralGroups(svgEl);
|
| 94 |
+
if (structure) {
|
| 95 |
+
setLoading({ text: '構造ベースのグループ化...', progress: `${structure.anchors.length}アンカー + ${structure.strokes.length}ストローク`, pct: 60 });
|
| 96 |
+
await tick();
|
| 97 |
+
const sGroups = buildStructuralColorGroups(structure, colors);
|
| 98 |
+
setStructuralGroups(sGroups);
|
| 99 |
+
} else {
|
| 100 |
+
setStructuralGroups(null);
|
| 101 |
+
setLoadError('色グループ構造(<g fill>)が見つかりません。Vectorizer.aiで Group By を Color に設定してエクスポートしてください。');
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
setVisibleLayers(null);
|
| 105 |
+
setLoading(null);
|
| 106 |
+
}, []);
|
| 107 |
+
|
| 108 |
+
const handleFile = useCallback((file) => {
|
| 109 |
+
if (!file || !file.name.toLowerCase().endsWith('.svg')) return;
|
| 110 |
+
const reader = new FileReader();
|
| 111 |
+
reader.onload = (e) => loadSVG(e.target.result);
|
| 112 |
+
reader.readAsText(file);
|
| 113 |
+
}, [loadSVG]);
|
| 114 |
+
|
| 115 |
+
const handleExport = useCallback(async () => {
|
| 116 |
+
if (!svgDoc || groups.length === 0) return;
|
| 117 |
+
setExportStatus('');
|
| 118 |
+
setLoading({ text: 'PSDエクスポート準備中...', progress: '', pct: 0 });
|
| 119 |
+
try {
|
| 120 |
+
await exportPSD(svgDoc, groups, uniqueColors, (text, progress, pct) => {
|
| 121 |
+
setLoading({ text, progress, pct });
|
| 122 |
+
});
|
| 123 |
+
setLoading(null);
|
| 124 |
+
setExportStatus('エクスポート完了!');
|
| 125 |
+
} catch (err) {
|
| 126 |
+
setLoading(null);
|
| 127 |
+
setExportStatus(`エラー: ${err.message}`);
|
| 128 |
+
console.error(err);
|
| 129 |
+
}
|
| 130 |
+
}, [svgDoc, groups, uniqueColors]);
|
| 131 |
+
|
| 132 |
+
const onDragOver = useCallback((e) => { e.preventDefault(); setDragActive(true); }, []);
|
| 133 |
+
const onDragLeave = useCallback(() => setDragActive(false), []);
|
| 134 |
+
const onDrop = useCallback((e) => {
|
| 135 |
+
e.preventDefault();
|
| 136 |
+
setDragActive(false);
|
| 137 |
+
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
|
| 138 |
+
}, [handleFile]);
|
| 139 |
+
|
| 140 |
+
const toggleLayer = (gi) => {
|
| 141 |
+
setVisibleLayers((prev) => {
|
| 142 |
+
if (prev === null) {
|
| 143 |
+
const next = new Set(Array.from({ length: groups.length }, (_, i) => i));
|
| 144 |
+
next.delete(gi);
|
| 145 |
+
return next;
|
| 146 |
+
}
|
| 147 |
+
const next = new Set(prev);
|
| 148 |
+
if (next.has(gi)) next.delete(gi);
|
| 149 |
+
else next.add(gi);
|
| 150 |
+
if (next.size === groups.length) return null;
|
| 151 |
+
return next;
|
| 152 |
+
});
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const isLayerVisible = (gi) => visibleLayers === null || visibleLayers.has(gi);
|
| 156 |
+
const loaded = uniqueColors.length > 0;
|
| 157 |
+
|
| 158 |
+
return (
|
| 159 |
+
<>
|
| 160 |
+
<div className="header">
|
| 161 |
+
<h1>SVG Color Grouper → PSD</h1>
|
| 162 |
+
</div>
|
| 163 |
+
<div
|
| 164 |
+
className={`container${dragActive ? ' drag-active' : ''}`}
|
| 165 |
+
onDragOver={onDragOver}
|
| 166 |
+
onDragLeave={onDragLeave}
|
| 167 |
+
onDrop={onDrop}
|
| 168 |
+
>
|
| 169 |
+
<div className="sidebar">
|
| 170 |
+
<div className="section">
|
| 171 |
+
<h3>入力</h3>
|
| 172 |
+
<label className="file-btn" htmlFor="svg-input">SVGファイルを選択</label>
|
| 173 |
+
<input type="file" id="svg-input" accept=".svg"
|
| 174 |
+
onChange={(e) => handleFile(e.target.files[0])} />
|
| 175 |
+
{loaded && !loadError && <div className="status">
|
| 176 |
+
{uniqueColors.length}色を検出 ({groups.length}グループ)
|
| 177 |
+
</div>}
|
| 178 |
+
{loadError && <div className="status error">{loadError}</div>}
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
{loaded && groups.length > 0 && (
|
| 182 |
+
<div className="section">
|
| 183 |
+
<h3>色グループ ({groups.length}グループ / クリックで表示切替)</h3>
|
| 184 |
+
<div className="swatches">
|
| 185 |
+
{groups.map((group, gi) => (
|
| 186 |
+
<SwatchGroup
|
| 187 |
+
key={gi} group={group} colors={uniqueColors} index={gi}
|
| 188 |
+
active={isLayerVisible(gi)}
|
| 189 |
+
onClick={() => toggleLayer(gi)}
|
| 190 |
+
/>
|
| 191 |
+
))}
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
)}
|
| 195 |
+
|
| 196 |
+
{loaded && groups.length > 0 && (
|
| 197 |
+
<div className="section">
|
| 198 |
+
<h3>出力</h3>
|
| 199 |
+
<button className="export-btn" onClick={handleExport}>
|
| 200 |
+
PSDエクスポート
|
| 201 |
+
</button>
|
| 202 |
+
{exportStatus && <div className="status">{exportStatus}</div>}
|
| 203 |
+
</div>
|
| 204 |
+
)}
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<div className="main">
|
| 208 |
+
{compositePreview ? (
|
| 209 |
+
<div className="preview-area" dangerouslySetInnerHTML={{ __html: compositePreview }} />
|
| 210 |
+
) : (
|
| 211 |
+
<div className="preview-area">
|
| 212 |
+
<div className="info">
|
| 213 |
+
{loaded ? 'レイヤーを選択してください' : 'SVGファイルをドラッグ&ドロップ、または左のボタンから選択'}
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
)}
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
{loading && (
|
| 221 |
+
<LoadingOverlay text={loading.text} progress={loading.progress} pct={loading.pct} />
|
| 222 |
+
)}
|
| 223 |
+
</>
|
| 224 |
+
);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function tick() {
|
| 228 |
+
return new Promise((r) => setTimeout(r, 0));
|
| 229 |
+
}
|
src/App.test.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
import { render, screen } from '@testing-library/react';
|
| 2 |
-
import App from './App';
|
| 3 |
-
|
| 4 |
-
test('renders learn react link', () => {
|
| 5 |
-
render(<App />);
|
| 6 |
-
const linkElement = screen.getByText(/learn react/i);
|
| 7 |
-
expect(linkElement).toBeInTheDocument();
|
| 8 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/colorUtils.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// sRGB → Linear
|
| 2 |
+
function srgbToLinear(c) {
|
| 3 |
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
// RGB [0-255] → XYZ
|
| 7 |
+
function rgbToXyz(r, g, b) {
|
| 8 |
+
const rl = srgbToLinear(r / 255);
|
| 9 |
+
const gl = srgbToLinear(g / 255);
|
| 10 |
+
const bl = srgbToLinear(b / 255);
|
| 11 |
+
return [
|
| 12 |
+
0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl,
|
| 13 |
+
0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl,
|
| 14 |
+
0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl,
|
| 15 |
+
];
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// XYZ → CIELAB
|
| 19 |
+
function xyzToLab(x, y, z) {
|
| 20 |
+
const Xn = 0.95047, Yn = 1.0, Zn = 1.08883;
|
| 21 |
+
const f = (v) => (v > 0.008856 ? Math.cbrt(v) : 7.787 * v + 16 / 116);
|
| 22 |
+
const fx = f(x / Xn), fy = f(y / Yn), fz = f(z / Zn);
|
| 23 |
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function rgbToLab(r, g, b) {
|
| 27 |
+
const [x, y, z] = rgbToXyz(r, g, b);
|
| 28 |
+
return xyzToLab(x, y, z);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function labDist(a, b) {
|
| 32 |
+
const dL = a[0] - b[0], da = a[1] - b[1], db = a[2] - b[2];
|
| 33 |
+
return Math.sqrt(dL * dL + da * da + db * db);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Parse any CSS color string → { r, g, b, hex }
|
| 37 |
+
const parseCtx = typeof document !== 'undefined'
|
| 38 |
+
? document.createElement('canvas').getContext('2d')
|
| 39 |
+
: null;
|
| 40 |
+
|
| 41 |
+
export function parseColor(str) {
|
| 42 |
+
if (!str || str === 'none' || str === 'transparent' || str === 'inherit' || str === 'currentColor') return null;
|
| 43 |
+
if (str.startsWith('url(')) return null;
|
| 44 |
+
if (!parseCtx) return null;
|
| 45 |
+
parseCtx.fillStyle = '#000000';
|
| 46 |
+
parseCtx.fillStyle = str;
|
| 47 |
+
const hex = parseCtx.fillStyle;
|
| 48 |
+
if (hex.startsWith('#')) {
|
| 49 |
+
const n = parseInt(hex.slice(1), 16);
|
| 50 |
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, hex };
|
| 51 |
+
}
|
| 52 |
+
const m = hex.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
| 53 |
+
if (m) {
|
| 54 |
+
const r = +m[1], g = +m[2], b = +m[3];
|
| 55 |
+
const h = '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
| 56 |
+
return { r, g, b, hex: h };
|
| 57 |
+
}
|
| 58 |
+
return null;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Extract unique colors from SVG element (all elements including <g>)
|
| 62 |
+
export function extractColorsFromSVG(svgEl) {
|
| 63 |
+
const colorMap = new Map();
|
| 64 |
+
const elements = svgEl.querySelectorAll('*');
|
| 65 |
+
for (const el of elements) {
|
| 66 |
+
const fill = el.getAttribute('fill');
|
| 67 |
+
const stroke = el.getAttribute('stroke');
|
| 68 |
+
const style = el.getAttribute('style') || '';
|
| 69 |
+
const styleFill = style.match(/fill\s*:\s*([^;]+)/);
|
| 70 |
+
const styleStroke = style.match(/stroke\s*:\s*([^;]+)/);
|
| 71 |
+
for (const raw of [fill, stroke, styleFill?.[1], styleStroke?.[1]]) {
|
| 72 |
+
if (!raw) continue;
|
| 73 |
+
const c = parseColor(raw.trim());
|
| 74 |
+
if (c) colorMap.set(c.hex, c);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
return Array.from(colorMap.values());
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Extract structural color groups from SVG:
|
| 81 |
+
// - Fill anchor colors from <g fill="..."> elements
|
| 82 |
+
// - Stroke colors from individual paths
|
| 83 |
+
// Returns { anchors: [{hex, r, g, b}], strokes: [{hex, r, g, b}] } or null if no structure
|
| 84 |
+
export function extractStructuralGroups(svgEl) {
|
| 85 |
+
const anchors = new Map(); // fill colors on <g> elements
|
| 86 |
+
const strokes = new Map(); // stroke colors on leaf elements
|
| 87 |
+
|
| 88 |
+
const gElements = svgEl.querySelectorAll('g');
|
| 89 |
+
for (const g of gElements) {
|
| 90 |
+
const fill = g.getAttribute('fill');
|
| 91 |
+
if (fill) {
|
| 92 |
+
const c = parseColor(fill.trim());
|
| 93 |
+
if (c) anchors.set(c.hex, c);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// If no <g fill> structure found, return null
|
| 98 |
+
if (anchors.size < 2) return null;
|
| 99 |
+
|
| 100 |
+
const allElements = svgEl.querySelectorAll('*');
|
| 101 |
+
for (const el of allElements) {
|
| 102 |
+
if (el.tagName === 'g' || el.tagName === 'svg' || el.tagName === 'defs') continue;
|
| 103 |
+
const stroke = el.getAttribute('stroke');
|
| 104 |
+
if (stroke) {
|
| 105 |
+
const c = parseColor(stroke.trim());
|
| 106 |
+
if (c && !anchors.has(c.hex)) strokes.set(c.hex, c);
|
| 107 |
+
}
|
| 108 |
+
const fill = el.getAttribute('fill');
|
| 109 |
+
if (fill) {
|
| 110 |
+
const c = parseColor(fill.trim());
|
| 111 |
+
if (c && !anchors.has(c.hex)) strokes.set(c.hex, c);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return {
|
| 116 |
+
anchors: Array.from(anchors.values()),
|
| 117 |
+
strokes: Array.from(strokes.values()),
|
| 118 |
+
};
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Get the color directly set on this element (no inheritance)
|
| 122 |
+
export function getOwnColor(el, attr) {
|
| 123 |
+
const style = el.getAttribute('style') || '';
|
| 124 |
+
const styleMatch = style.match(new RegExp(attr + '\\s*:\\s*([^;]+)'));
|
| 125 |
+
if (styleMatch) {
|
| 126 |
+
const c = parseColor(styleMatch[1].trim());
|
| 127 |
+
return c ? c.hex : null;
|
| 128 |
+
}
|
| 129 |
+
const val = el.getAttribute(attr);
|
| 130 |
+
if (val) {
|
| 131 |
+
const c = parseColor(val.trim());
|
| 132 |
+
return c ? c.hex : null;
|
| 133 |
+
}
|
| 134 |
+
return null;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Get effective color including inheritance from parent <g> elements
|
| 138 |
+
export function getEffectiveColor(el, attr) {
|
| 139 |
+
let node = el;
|
| 140 |
+
while (node && node.nodeType === 1) {
|
| 141 |
+
const color = getOwnColor(node, attr);
|
| 142 |
+
if (color) return color;
|
| 143 |
+
const val = node.getAttribute(attr);
|
| 144 |
+
if (val === 'none') return null;
|
| 145 |
+
node = node.parentNode;
|
| 146 |
+
}
|
| 147 |
+
return null;
|
| 148 |
+
}
|
src/grouping.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { rgbToLab, labDist } from './colorUtils.js';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* CIEDE2000 color difference.
|
| 5 |
+
* More perceptually uniform than Euclidean Lab distance.
|
| 6 |
+
*/
|
| 7 |
+
export function ciede2000(lab1, lab2) {
|
| 8 |
+
const [L1, a1, b1] = lab1;
|
| 9 |
+
const [L2, a2, b2] = lab2;
|
| 10 |
+
|
| 11 |
+
const C1 = Math.sqrt(a1 * a1 + b1 * b1);
|
| 12 |
+
const C2 = Math.sqrt(a2 * a2 + b2 * b2);
|
| 13 |
+
const Cmean = (C1 + C2) / 2;
|
| 14 |
+
const Cmean7 = Cmean ** 7;
|
| 15 |
+
const G = 0.5 * (1 - Math.sqrt(Cmean7 / (Cmean7 + 6103515625))); // 25^7
|
| 16 |
+
|
| 17 |
+
const a1p = a1 * (1 + G);
|
| 18 |
+
const a2p = a2 * (1 + G);
|
| 19 |
+
const C1p = Math.sqrt(a1p * a1p + b1 * b1);
|
| 20 |
+
const C2p = Math.sqrt(a2p * a2p + b2 * b2);
|
| 21 |
+
|
| 22 |
+
let h1p = Math.atan2(b1, a1p) * 180 / Math.PI;
|
| 23 |
+
if (h1p < 0) h1p += 360;
|
| 24 |
+
let h2p = Math.atan2(b2, a2p) * 180 / Math.PI;
|
| 25 |
+
if (h2p < 0) h2p += 360;
|
| 26 |
+
|
| 27 |
+
const dLp = L2 - L1;
|
| 28 |
+
const dCp = C2p - C1p;
|
| 29 |
+
|
| 30 |
+
let dhp;
|
| 31 |
+
if (C1p * C2p === 0) {
|
| 32 |
+
dhp = 0;
|
| 33 |
+
} else if (Math.abs(h2p - h1p) <= 180) {
|
| 34 |
+
dhp = h2p - h1p;
|
| 35 |
+
} else if (h2p - h1p > 180) {
|
| 36 |
+
dhp = h2p - h1p - 360;
|
| 37 |
+
} else {
|
| 38 |
+
dhp = h2p - h1p + 360;
|
| 39 |
+
}
|
| 40 |
+
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(dhp * Math.PI / 360);
|
| 41 |
+
|
| 42 |
+
const Lpmean = (L1 + L2) / 2;
|
| 43 |
+
const Cpmean = (C1p + C2p) / 2;
|
| 44 |
+
|
| 45 |
+
let Hpmean;
|
| 46 |
+
if (C1p * C2p === 0) {
|
| 47 |
+
Hpmean = h1p + h2p;
|
| 48 |
+
} else if (Math.abs(h1p - h2p) <= 180) {
|
| 49 |
+
Hpmean = (h1p + h2p) / 2;
|
| 50 |
+
} else if (h1p + h2p < 360) {
|
| 51 |
+
Hpmean = (h1p + h2p + 360) / 2;
|
| 52 |
+
} else {
|
| 53 |
+
Hpmean = (h1p + h2p - 360) / 2;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const T = 1
|
| 57 |
+
- 0.17 * Math.cos((Hpmean - 30) * Math.PI / 180)
|
| 58 |
+
+ 0.24 * Math.cos(2 * Hpmean * Math.PI / 180)
|
| 59 |
+
+ 0.32 * Math.cos((3 * Hpmean + 6) * Math.PI / 180)
|
| 60 |
+
- 0.20 * Math.cos((4 * Hpmean - 63) * Math.PI / 180);
|
| 61 |
+
|
| 62 |
+
const SL = 1 + 0.015 * (Lpmean - 50) ** 2 / Math.sqrt(20 + (Lpmean - 50) ** 2);
|
| 63 |
+
const SC = 1 + 0.045 * Cpmean;
|
| 64 |
+
const SH = 1 + 0.015 * Cpmean * T;
|
| 65 |
+
|
| 66 |
+
const Cpmean7 = Cpmean ** 7;
|
| 67 |
+
const RT = -2 * Math.sqrt(Cpmean7 / (Cpmean7 + 6103515625))
|
| 68 |
+
* Math.sin(60 * Math.exp(-(((Hpmean - 275) / 25) ** 2)) * Math.PI / 180);
|
| 69 |
+
|
| 70 |
+
return Math.sqrt(
|
| 71 |
+
(dLp / SL) ** 2 +
|
| 72 |
+
(dCp / SC) ** 2 +
|
| 73 |
+
(dHp / SH) ** 2 +
|
| 74 |
+
RT * (dCp / SC) * (dHp / SH)
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Assign stroke colors to anchor groups using nearest-anchor distance.
|
| 80 |
+
*
|
| 81 |
+
* @param {{ anchors: {hex,r,g,b}[], strokes: {hex,r,g,b}[] }} structure
|
| 82 |
+
* @param {{ hex: string, r: number, g: number, b: number }[]} allColors
|
| 83 |
+
* @param {{ distFn?: 'lab' | 'ciede2000' }} options
|
| 84 |
+
* @returns {number[][]} groups - array of color index arrays (anchor order)
|
| 85 |
+
*/
|
| 86 |
+
export function buildStructuralColorGroups(structure, allColors, options = {}) {
|
| 87 |
+
const { anchors, strokes } = structure;
|
| 88 |
+
const distFn = options.distFn || 'ciede2000';
|
| 89 |
+
|
| 90 |
+
// Build color index map
|
| 91 |
+
const colorIndex = new Map();
|
| 92 |
+
allColors.forEach((c, i) => colorIndex.set(c.hex, i));
|
| 93 |
+
|
| 94 |
+
// Compute anchor Labs
|
| 95 |
+
const anchorLabs = anchors.map(a => rgbToLab(a.r, a.g, a.b));
|
| 96 |
+
|
| 97 |
+
// Initialize groups with anchor colors
|
| 98 |
+
const groups = anchors.map(a => {
|
| 99 |
+
const idx = colorIndex.get(a.hex);
|
| 100 |
+
return idx !== undefined ? [idx] : [];
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// Distance function
|
| 104 |
+
const dist = distFn === 'ciede2000' ? ciede2000 : labDist;
|
| 105 |
+
|
| 106 |
+
// Assign each stroke to nearest anchor
|
| 107 |
+
for (const s of strokes) {
|
| 108 |
+
const idx = colorIndex.get(s.hex);
|
| 109 |
+
if (idx === undefined) continue;
|
| 110 |
+
const sLab = rgbToLab(s.r, s.g, s.b);
|
| 111 |
+
let bestGi = 0, bestDist = Infinity;
|
| 112 |
+
for (let gi = 0; gi < anchorLabs.length; gi++) {
|
| 113 |
+
const d = dist(sLab, anchorLabs[gi]);
|
| 114 |
+
if (d < bestDist) { bestDist = d; bestGi = gi; }
|
| 115 |
+
}
|
| 116 |
+
groups[bestGi].push(idx);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
return groups.filter(g => g.length > 0);
|
| 120 |
+
}
|
src/index.css
CHANGED
|
@@ -1,13 +1,52 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 2 |
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; min-height: 100vh; }
|
| 3 |
+
|
| 4 |
+
.header { background: #fff; padding: 16px 24px; border-bottom: 1px solid #d2d2d7; display: flex; align-items: center; gap: 16px; }
|
| 5 |
+
.header h1 { font-size: 18px; font-weight: 600; color: #1d1d1f; }
|
| 6 |
+
|
| 7 |
+
.container { display: flex; height: calc(100vh - 57px); }
|
| 8 |
+
|
| 9 |
+
.sidebar { width: 320px; min-width: 320px; background: #fff; border-right: 1px solid #d2d2d7; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
| 10 |
+
|
| 11 |
+
.main { flex: 1; padding: 16px; overflow: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
| 12 |
+
|
| 13 |
+
.section { background: #f5f5f7; border: 1px solid #d2d2d7; border-radius: 8px; padding: 12px; }
|
| 14 |
+
.section h3 { font-size: 13px; color: #86868b; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
| 15 |
+
|
| 16 |
+
input[type="file"] { display: none; }
|
| 17 |
+
.file-btn { display: inline-block; padding: 8px 16px; background: #0071e3; border: none; border-radius: 6px; color: #fff; cursor: pointer; font-size: 13px; text-align: center; width: 100%; transition: background 0.2s; }
|
| 18 |
+
.file-btn:hover { background: #0077ed; }
|
| 19 |
+
|
| 20 |
+
.slider-row { display: flex; align-items: center; gap: 8px; }
|
| 21 |
+
.slider-row input[type="range"] { flex: 1; accent-color: #0071e3; }
|
| 22 |
+
.slider-val { font-size: 14px; font-weight: 600; min-width: 28px; text-align: right; color: #1d1d1f; }
|
| 23 |
+
|
| 24 |
+
.swatches { display: flex; flex-direction: column; gap: 6px; }
|
| 25 |
+
.swatch-group { border: 1px solid #d2d2d7; border-radius: 6px; padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; background: #fff; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; }
|
| 26 |
+
.swatch-group:hover { border-color: #0071e3; }
|
| 27 |
+
.swatch-group-active { border-color: #0071e3; box-shadow: 0 0 0 2px rgba(0,113,227,0.3); }
|
| 28 |
+
.swatch-group .group-label { font-size: 10px; color: #86868b; width: 100%; margin-bottom: 2px; }
|
| 29 |
+
.swatch { width: 24px; height: 24px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.12); position: relative; }
|
| 30 |
+
.swatch:hover::after { content: attr(title); position: absolute; bottom: 110%; left: 50%; transform: translateX(-50%); background: #1d1d1f; color: #fff; padding: 2px 6px; border-radius: 3px; font-size: 10px; white-space: nowrap; z-index: 10; }
|
| 31 |
+
|
| 32 |
+
.export-btn { padding: 10px 16px; background: #30d158; border: none; border-radius: 6px; color: #fff; cursor: pointer; font-size: 14px; font-weight: 600; width: 100%; transition: background 0.2s; }
|
| 33 |
+
.export-btn:hover { background: #28c04e; }
|
| 34 |
+
.export-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 35 |
+
|
| 36 |
+
.preview-area { border: 1px solid #d2d2d7; border-radius: 8px; background: repeating-conic-gradient(#e8e8e8 0% 25%, #fff 0% 50%) 50% / 16px 16px; overflow: hidden; display: flex; align-items: center; justify-content: center; max-width: 100%; min-height: 300px; flex: 1; width: 100%; }
|
| 37 |
+
.preview-area svg { max-width: 100%; max-height: 100%; }
|
| 38 |
+
|
| 39 |
+
.info { font-size: 13px; color: #86868b; text-align: center; padding: 40px; }
|
| 40 |
+
.status { font-size: 12px; color: #86868b; margin-top: 4px; }
|
| 41 |
+
.status.error { color: #ff3b30; font-weight: 500; line-height: 1.4; }
|
| 42 |
+
|
| 43 |
+
/* Loading overlay */
|
| 44 |
+
.loading-overlay { position: fixed; inset: 0; background: rgba(255,255,255,0.85); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 1000; transition: opacity 0.2s; backdrop-filter: blur(4px); }
|
| 45 |
+
.spinner { width: 48px; height: 48px; border: 4px solid #d2d2d7; border-top-color: #0071e3; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
| 46 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 47 |
+
.loading-text { margin-top: 16px; font-size: 14px; color: #1d1d1f; }
|
| 48 |
+
.loading-progress { margin-top: 8px; font-size: 12px; color: #86868b; }
|
| 49 |
+
.progress-bar-wrap { width: 200px; height: 6px; background: #e8e8e8; border-radius: 3px; margin-top: 12px; overflow: hidden; }
|
| 50 |
+
.progress-bar-fill { height: 100%; background: #0071e3; border-radius: 3px; transition: width 0.3s; }
|
| 51 |
+
|
| 52 |
+
.drag-active { outline: 3px dashed #0071e3; outline-offset: -3px; }
|
src/index.js
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
-
import ReactDOM from 'react-dom/client';
|
| 3 |
-
import './index.css';
|
| 4 |
-
import App from './App';
|
| 5 |
-
import reportWebVitals from './reportWebVitals';
|
| 6 |
-
|
| 7 |
-
const root = ReactDOM.createRoot(document.getElementById('root'));
|
| 8 |
-
root.render(
|
| 9 |
-
<React.StrictMode>
|
| 10 |
-
<App />
|
| 11 |
-
</React.StrictMode>
|
| 12 |
-
);
|
| 13 |
-
|
| 14 |
-
// If you want to start measuring performance in your app, pass a function
|
| 15 |
-
// to log results (for example: reportWebVitals(console.log))
|
| 16 |
-
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
| 17 |
-
reportWebVitals();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/logo.svg
DELETED
src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
src/psdExport.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { writePsd } from 'ag-psd';
|
| 2 |
+
import { filterSvgByColors } from './svgFilter';
|
| 3 |
+
|
| 4 |
+
async function renderSvgToCanvas(svgEl, width, height) {
|
| 5 |
+
const serializer = new XMLSerializer();
|
| 6 |
+
const svgString = serializer.serializeToString(svgEl);
|
| 7 |
+
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
| 8 |
+
const url = URL.createObjectURL(blob);
|
| 9 |
+
|
| 10 |
+
const img = new Image();
|
| 11 |
+
await new Promise((resolve, reject) => {
|
| 12 |
+
img.onload = resolve;
|
| 13 |
+
img.onerror = reject;
|
| 14 |
+
img.src = url;
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
const canvas = document.createElement('canvas');
|
| 18 |
+
canvas.width = width;
|
| 19 |
+
canvas.height = height;
|
| 20 |
+
const ctx = canvas.getContext('2d');
|
| 21 |
+
ctx.clearRect(0, 0, width, height);
|
| 22 |
+
ctx.drawImage(img, 0, 0, width, height);
|
| 23 |
+
URL.revokeObjectURL(url);
|
| 24 |
+
return canvas;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export async function exportPSD(svgDoc, groups, uniqueColors, onProgress) {
|
| 28 |
+
const svgEl = svgDoc.querySelector('svg');
|
| 29 |
+
let width, height;
|
| 30 |
+
const vb = svgEl.getAttribute('viewBox');
|
| 31 |
+
if (vb) {
|
| 32 |
+
const parts = vb.split(/[\s,]+/).map(Number);
|
| 33 |
+
width = parts[2]; height = parts[3];
|
| 34 |
+
} else {
|
| 35 |
+
width = parseFloat(svgEl.getAttribute('width')) || 512;
|
| 36 |
+
height = parseFloat(svgEl.getAttribute('height')) || 512;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const maxDim = 4096;
|
| 40 |
+
if (width > maxDim || height > maxDim) {
|
| 41 |
+
const scale = maxDim / Math.max(width, height);
|
| 42 |
+
width = Math.round(width * scale);
|
| 43 |
+
height = Math.round(height * scale);
|
| 44 |
+
} else {
|
| 45 |
+
width = Math.round(width);
|
| 46 |
+
height = Math.round(height);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const total = groups.length;
|
| 50 |
+
const children = [];
|
| 51 |
+
|
| 52 |
+
for (let gi = 0; gi < total; gi++) {
|
| 53 |
+
onProgress?.('レイヤーをレンダリング中...', `${gi + 1} / ${total}`, Math.round((gi / (total + 1)) * 100));
|
| 54 |
+
await new Promise((r) => setTimeout(r, 0));
|
| 55 |
+
|
| 56 |
+
const groupColors = new Set(groups[gi].map((ci) => uniqueColors[ci].hex));
|
| 57 |
+
const filteredSvg = svgEl.cloneNode(true);
|
| 58 |
+
filterSvgByColors(filteredSvg, groupColors);
|
| 59 |
+
|
| 60 |
+
const canvas = await renderSvgToCanvas(filteredSvg, width, height);
|
| 61 |
+
children.push({
|
| 62 |
+
name: `Group ${gi + 1} (${groups[gi].map((ci) => uniqueColors[ci].hex).join(', ')})`,
|
| 63 |
+
canvas,
|
| 64 |
+
transparencyProtected: false,
|
| 65 |
+
hidden: false,
|
| 66 |
+
opacity: 255,
|
| 67 |
+
blendMode: 'normal',
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
onProgress?.('PSDを生成中...', '', 90);
|
| 72 |
+
await new Promise((r) => setTimeout(r, 0));
|
| 73 |
+
|
| 74 |
+
const buffer = writePsd({ width, height, channels: 4, bitsPerChannel: 8, colorMode: 3, children });
|
| 75 |
+
|
| 76 |
+
onProgress?.('ダウンロード中...', '', 100);
|
| 77 |
+
await new Promise((r) => setTimeout(r, 0));
|
| 78 |
+
|
| 79 |
+
const blob = new Blob([buffer], { type: 'application/octet-stream' });
|
| 80 |
+
const url = URL.createObjectURL(blob);
|
| 81 |
+
const a = document.createElement('a');
|
| 82 |
+
a.href = url;
|
| 83 |
+
a.download = 'color_groups.psd';
|
| 84 |
+
a.click();
|
| 85 |
+
URL.revokeObjectURL(url);
|
| 86 |
+
}
|
src/reportWebVitals.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
const reportWebVitals = onPerfEntry => {
|
| 2 |
-
if (onPerfEntry && onPerfEntry instanceof Function) {
|
| 3 |
-
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
| 4 |
-
getCLS(onPerfEntry);
|
| 5 |
-
getFID(onPerfEntry);
|
| 6 |
-
getFCP(onPerfEntry);
|
| 7 |
-
getLCP(onPerfEntry);
|
| 8 |
-
getTTFB(onPerfEntry);
|
| 9 |
-
});
|
| 10 |
-
}
|
| 11 |
-
};
|
| 12 |
-
|
| 13 |
-
export default reportWebVitals;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/setupTests.js
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
| 2 |
-
// allows you to do things like:
|
| 3 |
-
// expect(element).toHaveTextContent(/react/i)
|
| 4 |
-
// learn more: https://github.com/testing-library/jest-dom
|
| 5 |
-
import '@testing-library/jest-dom';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/svgFilter.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getOwnColor, getEffectiveColor } from './colorUtils';
|
| 2 |
+
|
| 3 |
+
export function filterSvgByColors(svgEl, groupColors) {
|
| 4 |
+
// Phase 1: resolve effective colors BEFORE modifying DOM
|
| 5 |
+
const allElements = svgEl.querySelectorAll('*');
|
| 6 |
+
const resolved = new Map();
|
| 7 |
+
for (const el of allElements) {
|
| 8 |
+
if (el.tagName === 'svg' || el.tagName === 'defs' || el.tagName === 'g') continue;
|
| 9 |
+
resolved.set(el, {
|
| 10 |
+
fill: getEffectiveColor(el, 'fill'),
|
| 11 |
+
stroke: getEffectiveColor(el, 'stroke'),
|
| 12 |
+
});
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Phase 2: apply visibility changes
|
| 16 |
+
for (const el of allElements) {
|
| 17 |
+
if (el.tagName === 'svg' || el.tagName === 'defs') continue;
|
| 18 |
+
|
| 19 |
+
if (el.tagName === 'g') {
|
| 20 |
+
const ownFill = getOwnColor(el, 'fill');
|
| 21 |
+
if (ownFill && !groupColors.has(ownFill)) {
|
| 22 |
+
el.setAttribute('fill', 'none');
|
| 23 |
+
}
|
| 24 |
+
const ownStroke = getOwnColor(el, 'stroke');
|
| 25 |
+
if (ownStroke && !groupColors.has(ownStroke)) {
|
| 26 |
+
el.setAttribute('stroke', 'none');
|
| 27 |
+
}
|
| 28 |
+
continue;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const r = resolved.get(el);
|
| 32 |
+
if (!r) continue;
|
| 33 |
+
const fillInGroup = r.fill && groupColors.has(r.fill);
|
| 34 |
+
const strokeInGroup = r.stroke && groupColors.has(r.stroke);
|
| 35 |
+
|
| 36 |
+
if (!fillInGroup && !strokeInGroup) {
|
| 37 |
+
el.setAttribute('fill', 'none');
|
| 38 |
+
el.setAttribute('stroke', 'none');
|
| 39 |
+
} else {
|
| 40 |
+
if (!fillInGroup) {
|
| 41 |
+
el.setAttribute('fill', 'none');
|
| 42 |
+
}
|
| 43 |
+
if (r.stroke && !strokeInGroup) {
|
| 44 |
+
el.setAttribute('stroke', 'none');
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function buildFilteredSvgHtml(svgDoc, groupColorSet) {
|
| 51 |
+
const svgEl = svgDoc.querySelector('svg').cloneNode(true);
|
| 52 |
+
filterSvgByColors(svgEl, groupColorSet);
|
| 53 |
+
return new XMLSerializer().serializeToString(svgEl);
|
| 54 |
+
}
|
vite.config.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
// https://vite.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
})
|