yeq6x commited on
Commit
d7da259
·
1 Parent(s): 0910dc2

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 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
- # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
-
3
- # dependencies
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: build/index.html
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": "react-template",
3
- "version": "0.1.0",
4
  "private": true,
5
- "dependencies": {
6
- "@testing-library/dom": "^10.4.0",
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
- "start": "react-scripts start",
17
- "build": "react-scripts build",
18
- "test": "react-scripts test",
19
- "eject": "react-scripts eject"
20
  },
21
- "eslintConfig": {
22
- "extends": [
23
- "react-app",
24
- "react-app/jest"
25
- ]
26
  },
27
- "browserslist": {
28
- "production": [
29
- ">0.2%",
30
- "not dead",
31
- "not op_mini all"
32
- ],
33
- "development": [
34
- "last 1 chrome version",
35
- "last 1 firefox version",
36
- "last 1 safari version"
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
- body {
2
- margin: 0;
3
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5
- sans-serif;
6
- -webkit-font-smoothing: antialiased;
7
- -moz-osx-font-smoothing: grayscale;
8
- }
9
-
10
- code {
11
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12
- monospace;
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
+ })