@trishchuk/coolors-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +39 -0
- package/.env +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +73 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +71 -0
- package/.github/pull_request_template.md +97 -0
- package/.github/workflows/ci.yml +127 -0
- package/.github/workflows/deploy-docs.yml +56 -0
- package/.github/workflows/release.yml +99 -0
- package/.mcp.json +12 -0
- package/.prettierignore +1 -0
- package/CLAUDE.md +201 -0
- package/DOCUMENTATION.md +274 -0
- package/GEMINI.md +54 -0
- package/LICENSE +21 -0
- package/README.md +401 -0
- package/demo/content_based_color.png +0 -0
- package/demo/music-player.html +621 -0
- package/demo/podcast-player.html +903 -0
- package/dist/bin/coolors-mcp.d.ts +1 -0
- package/dist/bin/coolors-mcp.js +154 -0
- package/dist/bin/coolors-mcp.js.map +1 -0
- package/dist/bin/server.d.ts +1 -0
- package/dist/bin/server.js +3292 -0
- package/dist/bin/server.js.map +1 -0
- package/dist/chunk-IQ7NN26V.js +114 -0
- package/dist/chunk-IQ7NN26V.js.map +1 -0
- package/dist/chunk-P3ARRKLS.js +1214 -0
- package/dist/chunk-P3ARRKLS.js.map +1 -0
- package/dist/color/index.d.ts +716 -0
- package/dist/color/index.js +153 -0
- package/dist/color/index.js.map +1 -0
- package/dist/coolors-mcp.d.ts +136 -0
- package/dist/coolors-mcp.js +7 -0
- package/dist/coolors-mcp.js.map +1 -0
- package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js +93 -0
- package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js.map +7 -0
- package/docs/.vitepress/cache/deps/_metadata.json +127 -0
- package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js +9 -0
- package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js.map +7 -0
- package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js +12683 -0
- package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js.map +7 -0
- package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js +9719 -0
- package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js.map +7 -0
- package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js +4710 -0
- package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js.map +7 -0
- package/docs/.vitepress/cache/deps/cytoscape.js +30278 -0
- package/docs/.vitepress/cache/deps/cytoscape.js.map +7 -0
- package/docs/.vitepress/cache/deps/dayjs.js +285 -0
- package/docs/.vitepress/cache/deps/dayjs.js.map +7 -0
- package/docs/.vitepress/cache/deps/debug.js +468 -0
- package/docs/.vitepress/cache/deps/debug.js.map +7 -0
- package/docs/.vitepress/cache/deps/package.json +3 -0
- package/docs/.vitepress/cache/deps/prismjs.js +1466 -0
- package/docs/.vitepress/cache/deps/prismjs.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js +228 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js +142 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js +27 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js +65 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js +53 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js +73 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +4507 -0
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +584 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js +1146 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js +1667 -0
- package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___minisearch.js +1814 -0
- package/docs/.vitepress/cache/deps/vitepress___minisearch.js.map +7 -0
- package/docs/.vitepress/cache/deps/vue.js +344 -0
- package/docs/.vitepress/cache/deps/vue.js.map +7 -0
- package/docs/.vitepress/components/ClientGrid.vue +125 -0
- package/docs/.vitepress/components/CodeBlock.vue +231 -0
- package/docs/.vitepress/components/ConfigModal.vue +477 -0
- package/docs/.vitepress/components/DiagramModal.vue +528 -0
- package/docs/.vitepress/components/TroubleshootingModal.vue +472 -0
- package/docs/.vitepress/config.js +162 -0
- package/docs/.vitepress/theme/FundingLayout.vue +251 -0
- package/docs/.vitepress/theme/Layout.vue +134 -0
- package/docs/.vitepress/theme/components/AdBanner.vue +317 -0
- package/docs/.vitepress/theme/components/AdPlaceholder.vue +78 -0
- package/docs/.vitepress/theme/components/FundingEffects.vue +322 -0
- package/docs/.vitepress/theme/components/FundingHero.vue +345 -0
- package/docs/.vitepress/theme/components/SupportSection.vue +511 -0
- package/docs/.vitepress/theme/custom-app.css +339 -0
- package/docs/.vitepress/theme/custom.css +699 -0
- package/docs/.vitepress/theme/index.js +25 -0
- package/docs/README.md +198 -0
- package/docs/concepts/accessibility.md +473 -0
- package/docs/concepts/color-spaces.md +222 -0
- package/docs/concepts/distance-metrics.md +384 -0
- package/docs/concepts/hct.md +261 -0
- package/docs/concepts/image-analysis.md +396 -0
- package/docs/concepts/material-design.md +306 -0
- package/docs/concepts/theme-matching.md +399 -0
- package/docs/examples/basic-colors.md +490 -0
- package/docs/examples/creating-themes.md +898 -0
- package/docs/examples/css-refactoring.md +824 -0
- package/docs/examples/image-extraction.md +882 -0
- package/docs/getting-started.md +366 -0
- package/docs/index.md +190 -0
- package/docs/installation.md +157 -0
- package/docs/tools/README.md +234 -0
- package/docs/tools/accessibility.md +614 -0
- package/docs/tools/color-operations.md +374 -0
- package/docs/tools/image-extraction.md +624 -0
- package/docs/tools/material-design.md +347 -0
- package/docs/tools/theme-matching.md +552 -0
- package/eslint.config.ts +14 -0
- package/examples/theme-matching.md +113 -0
- package/jsr.json +7 -0
- package/mcp-config.json +8 -0
- package/note.md +35 -0
- package/package.json +122 -0
- package/research_results.md +53 -0
- package/src/bin/coolors-mcp.ts +194 -0
- package/src/bin/server.ts +61 -0
- package/src/color/__tests__/conversions-argb.test.ts +198 -0
- package/src/color/__tests__/extract-colors.test.ts +360 -0
- package/src/color/__tests__/image-utils.test.ts +242 -0
- package/src/color/__tests__/reference-colors.test.ts +278 -0
- package/src/color/__tests__/round-trip.test.ts +197 -0
- package/src/color/conversions.test.ts +402 -0
- package/src/color/conversions.ts +393 -0
- package/src/color/dislike/__tests__/dislike-analyzer.test.ts +248 -0
- package/src/color/dislike/dislike-analyzer.ts +114 -0
- package/src/color/extract-colors.ts +228 -0
- package/src/color/hct/__tests__/hct-class.test.ts +232 -0
- package/src/color/hct/harmonization.ts +204 -0
- package/src/color/hct/hct-class.ts +109 -0
- package/src/color/hct/hct-solver.ts +168 -0
- package/src/color/hct/index.ts +39 -0
- package/src/color/hct/tonal-palette.ts +211 -0
- package/src/color/hct/types.ts +88 -0
- package/src/color/image-utils.ts +79 -0
- package/src/color/index.ts +87 -0
- package/src/color/material-theme.ts +157 -0
- package/src/color/metrics.test.ts +276 -0
- package/src/color/metrics.ts +281 -0
- package/src/color/quantize/__tests__/quantizer_celebi.test.ts +195 -0
- package/src/color/quantize/lab_point_provider.ts +55 -0
- package/src/color/quantize/point_provider.ts +27 -0
- package/src/color/quantize/quantizer_celebi.ts +51 -0
- package/src/color/quantize/quantizer_celebi_test.ts +71 -0
- package/src/color/quantize/quantizer_map.ts +47 -0
- package/src/color/quantize/quantizer_wsmeans.ts +232 -0
- package/src/color/quantize/quantizer_wu.ts +472 -0
- package/src/color/score/__tests__/score.test.ts +224 -0
- package/src/color/score/score.ts +175 -0
- package/src/color/types.ts +151 -0
- package/src/color/utils/color_utils.ts +292 -0
- package/src/color/utils/math_utils.ts +145 -0
- package/src/color/utils.test.ts +403 -0
- package/src/color/utils.ts +315 -0
- package/src/constants.ts +5 -0
- package/src/coolors-mcp.ts +37 -0
- package/src/examples/addition.ts +333 -0
- package/src/examples/color-demo.ts +125 -0
- package/src/examples/custom-logger.ts +201 -0
- package/src/examples/oauth-server.ts +113 -0
- package/src/examples/session-context.ts +269 -0
- package/src/session.ts +116 -0
- package/src/theme/__tests__/matcher.test.ts +180 -0
- package/src/theme/__tests__/parser.test.ts +148 -0
- package/src/theme/__tests__/refactor.test.ts +224 -0
- package/src/theme/index.ts +34 -0
- package/src/theme/matcher.ts +395 -0
- package/src/theme/parser.ts +392 -0
- package/src/theme/refactor.ts +360 -0
- package/src/theme/types.ts +152 -0
- package/src/tools/__tests__/gradient-generator.test.ts +206 -0
- package/src/tools/__tests__/palette-with-locks.test.ts +109 -0
- package/src/tools/color-conversion.tool.ts +54 -0
- package/src/tools/color-distance.tool.ts +41 -0
- package/src/tools/colors.ts +31 -0
- package/src/tools/contrast-checker.tool.ts +37 -0
- package/src/tools/dislike-analyzer.tool.ts +247 -0
- package/src/tools/gradient-generator.tool.ts +250 -0
- package/src/tools/image-extraction.tools.ts +289 -0
- package/src/tools/index.ts +39 -0
- package/src/tools/material-theme.tools.ts +250 -0
- package/src/tools/palette-generator.tool.ts +135 -0
- package/src/tools/palette-with-locks.tool.ts +221 -0
- package/src/tools/registry.ts +142 -0
- package/src/tools/simple-tools.ts +37 -0
- package/src/tools/theme-matching.tools.ts +334 -0
- package/src/types.ts +182 -0
- package/src/utils.ts +22 -0
- package/tsconfig.json +8 -0
- package/vitest.config.js +15 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Hct } from "../hct/hct-class.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check and/or fix universally disliked colors.
|
|
5
|
+
*
|
|
6
|
+
* Color science studies of color preference indicate universal distaste for
|
|
7
|
+
* dark yellow-greens, and also show this is correlated to distaste for
|
|
8
|
+
* biological waste and rotting food.
|
|
9
|
+
*
|
|
10
|
+
* See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook
|
|
11
|
+
* of Color Psychology (2015).
|
|
12
|
+
*/
|
|
13
|
+
export class DislikeAnalyzer {
|
|
14
|
+
/**
|
|
15
|
+
* Analyze a batch of colors and return statistics
|
|
16
|
+
* @param colors Array of HCT colors
|
|
17
|
+
* @return Statistics about disliked colors
|
|
18
|
+
*/
|
|
19
|
+
static analyzeBatch(colors: Hct[]): {
|
|
20
|
+
disliked: number;
|
|
21
|
+
dislikedIndices: number[];
|
|
22
|
+
percentage: number;
|
|
23
|
+
total: number;
|
|
24
|
+
} {
|
|
25
|
+
const dislikedIndices: number[] = [];
|
|
26
|
+
|
|
27
|
+
colors.forEach((color, index) => {
|
|
28
|
+
if (DislikeAnalyzer.isDisliked(color)) {
|
|
29
|
+
dislikedIndices.push(index);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
disliked: dislikedIndices.length,
|
|
35
|
+
dislikedIndices,
|
|
36
|
+
percentage: (dislikedIndices.length / colors.length) * 100,
|
|
37
|
+
total: colors.length,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fix all disliked colors in a batch
|
|
43
|
+
* @param colors Array of HCT colors
|
|
44
|
+
* @return Array with disliked colors fixed
|
|
45
|
+
*/
|
|
46
|
+
static fixBatch(colors: Hct[]): Hct[] {
|
|
47
|
+
return colors.map((color) => DislikeAnalyzer.fixIfDisliked(color));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* If a color is disliked, lighten it to make it likable.
|
|
52
|
+
*
|
|
53
|
+
* @param hct A color to be judged.
|
|
54
|
+
* @return A new color if the original color is disliked, or the original
|
|
55
|
+
* color if it is acceptable.
|
|
56
|
+
*/
|
|
57
|
+
static fixIfDisliked(hct: Hct): Hct {
|
|
58
|
+
if (DislikeAnalyzer.isDisliked(hct)) {
|
|
59
|
+
return Hct.from(hct.hue, hct.chroma, 70.0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return hct;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fix a hex color if it's disliked
|
|
67
|
+
* @param hex Hex color string
|
|
68
|
+
* @return Fixed hex color or original if not disliked
|
|
69
|
+
*/
|
|
70
|
+
static fixIfDislikedHex(hex: string): string {
|
|
71
|
+
const argb = parseInt(hex.replace("#", ""), 16) | 0xff000000;
|
|
72
|
+
const hct = Hct.fromInt(argb);
|
|
73
|
+
const fixed = DislikeAnalyzer.fixIfDisliked(hct);
|
|
74
|
+
|
|
75
|
+
if (fixed === hct) {
|
|
76
|
+
return hex;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fixedArgb = fixed.toInt();
|
|
80
|
+
const r = (fixedArgb >> 16) & 0xff;
|
|
81
|
+
const g = (fixedArgb >> 8) & 0xff;
|
|
82
|
+
const b = fixedArgb & 0xff;
|
|
83
|
+
|
|
84
|
+
return "#" + [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns true if a color is disliked.
|
|
89
|
+
*
|
|
90
|
+
* @param hct A color to be judged.
|
|
91
|
+
* @return Whether the color is disliked.
|
|
92
|
+
*
|
|
93
|
+
* Disliked is defined as a dark yellow-green that is not neutral.
|
|
94
|
+
* Specifically: hue 90-111°, chroma > 16, tone < 65
|
|
95
|
+
*/
|
|
96
|
+
static isDisliked(hct: Hct): boolean {
|
|
97
|
+
const huePasses =
|
|
98
|
+
Math.round(hct.hue) >= 90.0 && Math.round(hct.hue) <= 111.0;
|
|
99
|
+
const chromaPasses = Math.round(hct.chroma) > 16.0;
|
|
100
|
+
const tonePasses = Math.round(hct.tone) < 65.0;
|
|
101
|
+
|
|
102
|
+
return huePasses && chromaPasses && tonePasses;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if a color is in the "bile zone" - universally disliked colors
|
|
107
|
+
* @param hex Hex color string
|
|
108
|
+
* @return Whether the color is disliked
|
|
109
|
+
*/
|
|
110
|
+
static isDislikedHex(hex: string): boolean {
|
|
111
|
+
const hct = Hct.fromInt(parseInt(hex.replace("#", ""), 16) | 0xff000000);
|
|
112
|
+
return DislikeAnalyzer.isDisliked(hct);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image color extraction using Material Design quantization
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DislikeAnalyzer } from "./dislike/dislike-analyzer.js";
|
|
6
|
+
import { Hct } from "./hct/index.js";
|
|
7
|
+
import {
|
|
8
|
+
filterExtremeTones,
|
|
9
|
+
imageDataToPixels,
|
|
10
|
+
samplePixels,
|
|
11
|
+
} from "./image-utils.js";
|
|
12
|
+
import { QuantizerCelebi } from "./quantize/quantizer_celebi.js";
|
|
13
|
+
import { Score } from "./score/score.js";
|
|
14
|
+
import * as utils from "./utils/color_utils.js";
|
|
15
|
+
|
|
16
|
+
export interface ExtractedColor {
|
|
17
|
+
hct: { c: number; h: number; t: number };
|
|
18
|
+
hex: string;
|
|
19
|
+
percentage: number;
|
|
20
|
+
population: number;
|
|
21
|
+
rgb: { b: number; g: number; r: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExtractionOptions {
|
|
25
|
+
filter?: boolean;
|
|
26
|
+
fixDislikedColors?: boolean;
|
|
27
|
+
maxColors?: number;
|
|
28
|
+
quality?: "high" | "low" | "medium";
|
|
29
|
+
scoringEnabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const QUALITY_SETTINGS = {
|
|
33
|
+
high: { maxPixels: 25000, quantizeColors: 256 },
|
|
34
|
+
low: { maxPixels: 5000, quantizeColors: 64 },
|
|
35
|
+
medium: { maxPixels: 10000, quantizeColors: 128 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract dominant colors from image data
|
|
40
|
+
*/
|
|
41
|
+
export function extractColors(
|
|
42
|
+
imageData: {
|
|
43
|
+
data: number[] | Uint8ClampedArray;
|
|
44
|
+
height: number;
|
|
45
|
+
width: number;
|
|
46
|
+
},
|
|
47
|
+
options: ExtractionOptions = {},
|
|
48
|
+
): ExtractedColor[] {
|
|
49
|
+
const {
|
|
50
|
+
filter = true,
|
|
51
|
+
fixDislikedColors = false,
|
|
52
|
+
maxColors = 5,
|
|
53
|
+
quality = "medium",
|
|
54
|
+
scoringEnabled = true,
|
|
55
|
+
} = options;
|
|
56
|
+
|
|
57
|
+
const qualitySettings = QUALITY_SETTINGS[quality];
|
|
58
|
+
|
|
59
|
+
// Convert image to pixels
|
|
60
|
+
let pixels = imageDataToPixels(imageData);
|
|
61
|
+
|
|
62
|
+
// Sample for performance
|
|
63
|
+
pixels = samplePixels(pixels, qualitySettings.maxPixels);
|
|
64
|
+
|
|
65
|
+
// Optionally filter extreme tones
|
|
66
|
+
if (filter) {
|
|
67
|
+
pixels = filterExtremeTones(pixels);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Quantize colors using Celebi algorithm
|
|
71
|
+
const quantized = QuantizerCelebi.quantize(
|
|
72
|
+
pixels,
|
|
73
|
+
qualitySettings.quantizeColors,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Score and select best colors
|
|
77
|
+
let selectedColors: number[];
|
|
78
|
+
if (scoringEnabled) {
|
|
79
|
+
selectedColors = Score.score(quantized, {
|
|
80
|
+
desired: maxColors,
|
|
81
|
+
filter: filter,
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
// Simple selection by population
|
|
85
|
+
const sorted = Array.from(quantized.entries())
|
|
86
|
+
.sort((a, b) => b[1] - a[1])
|
|
87
|
+
.slice(0, maxColors)
|
|
88
|
+
.map((entry) => entry[0]);
|
|
89
|
+
selectedColors = sorted;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Calculate total population for percentages
|
|
93
|
+
const totalPopulation = Array.from(quantized.values()).reduce(
|
|
94
|
+
(sum, pop) => sum + pop,
|
|
95
|
+
0,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Convert to output format
|
|
99
|
+
return selectedColors.map((argb) => {
|
|
100
|
+
let hct = Hct.fromInt(argb);
|
|
101
|
+
|
|
102
|
+
// Fix disliked colors if requested
|
|
103
|
+
if (fixDislikedColors && DislikeAnalyzer.isDisliked(hct)) {
|
|
104
|
+
hct = DislikeAnalyzer.fixIfDisliked(hct);
|
|
105
|
+
argb = hct.toInt();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const r = utils.redFromArgb(argb);
|
|
109
|
+
const g = utils.greenFromArgb(argb);
|
|
110
|
+
const b = utils.blueFromArgb(argb);
|
|
111
|
+
const population = quantized.get(argb) || 0;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
hct: { c: hct.chroma, h: hct.hue, t: hct.tone },
|
|
115
|
+
hex: rgbToHex(r, g, b),
|
|
116
|
+
percentage: (population / totalPopulation) * 100,
|
|
117
|
+
population,
|
|
118
|
+
rgb: { b, g, r },
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract a color palette suitable for UI themes
|
|
125
|
+
*/
|
|
126
|
+
export function extractThemePalette(imageData: {
|
|
127
|
+
data: number[] | Uint8ClampedArray;
|
|
128
|
+
height: number;
|
|
129
|
+
width: number;
|
|
130
|
+
}): {
|
|
131
|
+
error?: ExtractedColor;
|
|
132
|
+
neutral?: ExtractedColor;
|
|
133
|
+
primary: ExtractedColor;
|
|
134
|
+
secondary?: ExtractedColor;
|
|
135
|
+
tertiary?: ExtractedColor;
|
|
136
|
+
} {
|
|
137
|
+
// Extract with high quality and scoring, fixing disliked colors
|
|
138
|
+
const colors = extractColors(imageData, {
|
|
139
|
+
filter: true,
|
|
140
|
+
fixDislikedColors: true, // Always fix disliked colors for themes
|
|
141
|
+
maxColors: 8,
|
|
142
|
+
quality: "high",
|
|
143
|
+
scoringEnabled: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (colors.length === 0) {
|
|
147
|
+
throw new Error("No colors could be extracted from image");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result: {
|
|
151
|
+
error?: ExtractedColor;
|
|
152
|
+
neutral?: ExtractedColor;
|
|
153
|
+
primary: ExtractedColor;
|
|
154
|
+
secondary?: ExtractedColor;
|
|
155
|
+
tertiary?: ExtractedColor;
|
|
156
|
+
} = {
|
|
157
|
+
primary: colors[0],
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Assign additional colors based on hue differences
|
|
161
|
+
if (colors.length > 1) {
|
|
162
|
+
// Find color with most different hue from primary
|
|
163
|
+
const primaryHue = colors[0].hct.h;
|
|
164
|
+
let maxHueDiff = 0;
|
|
165
|
+
let secondaryIndex = 1;
|
|
166
|
+
|
|
167
|
+
for (let i = 1; i < Math.min(colors.length, 4); i++) {
|
|
168
|
+
const hueDiff = Math.abs(hueDifference(primaryHue, colors[i].hct.h));
|
|
169
|
+
if (hueDiff > maxHueDiff) {
|
|
170
|
+
maxHueDiff = hueDiff;
|
|
171
|
+
secondaryIndex = i;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
result.secondary = colors[secondaryIndex];
|
|
176
|
+
|
|
177
|
+
// Find tertiary (different from both primary and secondary)
|
|
178
|
+
if (colors.length > 2) {
|
|
179
|
+
const secondaryHue = colors[secondaryIndex].hct.h;
|
|
180
|
+
let bestTertiaryIndex = -1;
|
|
181
|
+
let bestScore = 0;
|
|
182
|
+
|
|
183
|
+
for (let i = 1; i < colors.length; i++) {
|
|
184
|
+
if (i === secondaryIndex) continue;
|
|
185
|
+
|
|
186
|
+
const hue = colors[i].hct.h;
|
|
187
|
+
const primaryDiff = Math.abs(hueDifference(primaryHue, hue));
|
|
188
|
+
const secondaryDiff = Math.abs(hueDifference(secondaryHue, hue));
|
|
189
|
+
const score = Math.min(primaryDiff, secondaryDiff);
|
|
190
|
+
|
|
191
|
+
if (score > bestScore) {
|
|
192
|
+
bestScore = score;
|
|
193
|
+
bestTertiaryIndex = i;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (bestTertiaryIndex !== -1) {
|
|
198
|
+
result.tertiary = colors[bestTertiaryIndex];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Find a neutral color (low chroma)
|
|
204
|
+
const neutral = colors.find((c) => c.hct.c < 20);
|
|
205
|
+
if (neutral) {
|
|
206
|
+
result.neutral = neutral;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Error color (prefer red/orange hue if available, 0-40 or 350-360)
|
|
210
|
+
const errorColor = colors.find((c) => c.hct.h >= 350 || c.hct.h <= 40);
|
|
211
|
+
if (errorColor) {
|
|
212
|
+
result.error = errorColor;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hueDifference(h1: number, h2: number): number {
|
|
219
|
+
const diff = Math.abs(h1 - h2);
|
|
220
|
+
return diff > 180 ? 360 - diff : diff;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
224
|
+
return (
|
|
225
|
+
"#" +
|
|
226
|
+
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for HCT class (Material Color Utilities compatible)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import * as utils from "../../utils/color_utils";
|
|
8
|
+
import { Hct } from "../hct-class";
|
|
9
|
+
|
|
10
|
+
describe("Hct Class", () => {
|
|
11
|
+
describe("from static method", () => {
|
|
12
|
+
it("should create HCT from hue, chroma, and tone", () => {
|
|
13
|
+
const hct = Hct.from(180, 50, 50);
|
|
14
|
+
|
|
15
|
+
expect(hct.hue).toBeCloseTo(180, -1); // Within 10 degrees
|
|
16
|
+
// Chroma might be adjusted to achievable value
|
|
17
|
+
expect(hct.chroma).toBeGreaterThan(30); // At least some chroma
|
|
18
|
+
expect(hct.chroma).toBeLessThanOrEqual(50); // Not more than requested
|
|
19
|
+
expect(hct.tone).toBeCloseTo(50, -1); // Within 10 units
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should handle zero chroma (grayscale)", () => {
|
|
23
|
+
const hct = Hct.from(0, 0, 50);
|
|
24
|
+
|
|
25
|
+
expect(hct.chroma).toBeCloseTo(0, 1);
|
|
26
|
+
expect(hct.tone).toBeCloseTo(50, -1); // Within 10 units
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should handle extreme tones", () => {
|
|
30
|
+
const black = Hct.from(0, 0, 0);
|
|
31
|
+
const white = Hct.from(0, 0, 100);
|
|
32
|
+
|
|
33
|
+
expect(black.tone).toBeCloseTo(0, 0);
|
|
34
|
+
expect(white.tone).toBeCloseTo(100, 0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should clamp chroma to achievable values", () => {
|
|
38
|
+
// Very high chroma might not be achievable
|
|
39
|
+
const hct = Hct.from(0, 200, 50);
|
|
40
|
+
|
|
41
|
+
// Chroma should be clamped to achievable value
|
|
42
|
+
expect(hct.chroma).toBeLessThanOrEqual(150);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("fromInt static method", () => {
|
|
47
|
+
it("should create HCT from ARGB integer", () => {
|
|
48
|
+
const argb = utils.argbFromRgb(255, 0, 0); // Red
|
|
49
|
+
const hct = Hct.fromInt(argb);
|
|
50
|
+
|
|
51
|
+
// Red should have hue around 0-30
|
|
52
|
+
expect(hct.hue).toBeGreaterThanOrEqual(0);
|
|
53
|
+
expect(hct.hue).toBeLessThan(60);
|
|
54
|
+
expect(hct.chroma).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should handle grayscale colors", () => {
|
|
58
|
+
const gray = utils.argbFromRgb(128, 128, 128);
|
|
59
|
+
const hct = Hct.fromInt(gray);
|
|
60
|
+
|
|
61
|
+
expect(hct.chroma).toBeCloseTo(0, 1);
|
|
62
|
+
expect(hct.tone).toBeCloseTo(50, -1); // Within 10 units
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should handle primary colors", () => {
|
|
66
|
+
const red = utils.argbFromRgb(255, 0, 0);
|
|
67
|
+
const green = utils.argbFromRgb(0, 255, 0);
|
|
68
|
+
const blue = utils.argbFromRgb(0, 0, 255);
|
|
69
|
+
|
|
70
|
+
const hctRed = Hct.fromInt(red);
|
|
71
|
+
const hctGreen = Hct.fromInt(green);
|
|
72
|
+
const hctBlue = Hct.fromInt(blue);
|
|
73
|
+
|
|
74
|
+
// Check that primary colors have high chroma
|
|
75
|
+
expect(hctRed.chroma).toBeGreaterThan(100);
|
|
76
|
+
expect(hctGreen.chroma).toBeGreaterThan(100);
|
|
77
|
+
expect(hctBlue.chroma).toBeGreaterThan(80);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("toInt method", () => {
|
|
82
|
+
it("should convert back to ARGB integer", () => {
|
|
83
|
+
const originalArgb = utils.argbFromRgb(128, 64, 192);
|
|
84
|
+
const hct = Hct.fromInt(originalArgb);
|
|
85
|
+
const resultArgb = hct.toInt();
|
|
86
|
+
|
|
87
|
+
// Should be close to original (some loss due to color space conversion)
|
|
88
|
+
const originalRgb = {
|
|
89
|
+
b: utils.blueFromArgb(originalArgb),
|
|
90
|
+
g: utils.greenFromArgb(originalArgb),
|
|
91
|
+
r: utils.redFromArgb(originalArgb),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const resultRgb = {
|
|
95
|
+
b: utils.blueFromArgb(resultArgb),
|
|
96
|
+
g: utils.greenFromArgb(resultArgb),
|
|
97
|
+
r: utils.redFromArgb(resultArgb),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
expect(resultRgb.r).toBeCloseTo(originalRgb.r, -1); // Within 10
|
|
101
|
+
expect(resultRgb.g).toBeCloseTo(originalRgb.g, -1);
|
|
102
|
+
expect(resultRgb.b).toBeCloseTo(originalRgb.b, -1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should maintain opaque alpha channel", () => {
|
|
106
|
+
const hct = Hct.from(180, 50, 50);
|
|
107
|
+
const argb = hct.toInt();
|
|
108
|
+
|
|
109
|
+
// Alpha should be 255 (fully opaque)
|
|
110
|
+
const alpha = (argb >> 24) & 0xff;
|
|
111
|
+
expect(alpha).toBe(0xff);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("getters", () => {
|
|
116
|
+
it("should return correct hue", () => {
|
|
117
|
+
const hct = Hct.from(123.45, 50, 50);
|
|
118
|
+
expect(hct.hue).toBeCloseTo(123.45, 0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should return correct chroma", () => {
|
|
122
|
+
const hct = Hct.from(180, 45.67, 50);
|
|
123
|
+
// Chroma might be adjusted to achievable value
|
|
124
|
+
expect(hct.chroma).toBeGreaterThan(30); // At least some chroma
|
|
125
|
+
expect(hct.chroma).toBeLessThanOrEqual(45.67); // Not more than requested
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should return correct tone", () => {
|
|
129
|
+
const hct = Hct.from(180, 50, 78.9);
|
|
130
|
+
expect(hct.tone).toBeCloseTo(78.9, 0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("setters", () => {
|
|
135
|
+
it("should update hue", () => {
|
|
136
|
+
const hct = Hct.from(0, 50, 50);
|
|
137
|
+
hct.hue = 180;
|
|
138
|
+
|
|
139
|
+
expect(hct.hue).toBeCloseTo(180, -1); // Within 10 degrees
|
|
140
|
+
// Chroma might adjust based on new hue
|
|
141
|
+
expect(hct.chroma).toBeGreaterThan(30); // At least some chroma
|
|
142
|
+
expect(hct.tone).toBeCloseTo(50, -1); // Within 10 tone units
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should update chroma", () => {
|
|
146
|
+
const hct = Hct.from(180, 30, 50);
|
|
147
|
+
hct.chroma = 60;
|
|
148
|
+
|
|
149
|
+
// Chroma might be clamped to achievable value
|
|
150
|
+
expect(hct.chroma).toBeGreaterThan(30); // At least original chroma
|
|
151
|
+
expect(hct.chroma).toBeLessThanOrEqual(60); // Not more than requested
|
|
152
|
+
// Hue should be maintained, allowing for some adjustment
|
|
153
|
+
expect(hct.hue).toBeGreaterThan(170);
|
|
154
|
+
expect(hct.hue).toBeLessThan(190);
|
|
155
|
+
expect(hct.tone).toBeCloseTo(50, -1); // Within 10 tone units
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should update tone", () => {
|
|
159
|
+
const hct = Hct.from(180, 50, 30);
|
|
160
|
+
hct.tone = 70;
|
|
161
|
+
|
|
162
|
+
expect(hct.tone).toBeCloseTo(70, 0); // Very close
|
|
163
|
+
// Hue might adjust slightly
|
|
164
|
+
expect(hct.hue).toBeGreaterThan(170);
|
|
165
|
+
expect(hct.hue).toBeLessThan(190);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should handle hue wrapping", () => {
|
|
169
|
+
const hct = Hct.from(350, 50, 50);
|
|
170
|
+
hct.hue = 370; // Should wrap to 10
|
|
171
|
+
|
|
172
|
+
expect(hct.hue).toBeCloseTo(10, 0); // Should be close
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should clamp tone to valid range", () => {
|
|
176
|
+
const hct = Hct.from(180, 50, 50);
|
|
177
|
+
|
|
178
|
+
hct.tone = -10;
|
|
179
|
+
expect(hct.tone).toBeCloseTo(0, 1);
|
|
180
|
+
|
|
181
|
+
hct.tone = 110;
|
|
182
|
+
expect(hct.tone).toBeCloseTo(100, 1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should adjust chroma when not achievable", () => {
|
|
186
|
+
const hct = Hct.from(180, 50, 95); // High tone
|
|
187
|
+
hct.chroma = 100; // High chroma at high tone is difficult
|
|
188
|
+
|
|
189
|
+
// Chroma should be adjusted to achievable value
|
|
190
|
+
expect(hct.chroma).toBeLessThanOrEqual(100);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("round-trip conversions", () => {
|
|
195
|
+
it("should maintain values through round-trip", () => {
|
|
196
|
+
const originalHct = Hct.from(240, 40, 60);
|
|
197
|
+
const argb = originalHct.toInt();
|
|
198
|
+
const newHct = Hct.fromInt(argb);
|
|
199
|
+
|
|
200
|
+
expect(newHct.hue).toBeCloseTo(originalHct.hue, 0);
|
|
201
|
+
expect(newHct.chroma).toBeCloseTo(originalHct.chroma, 1);
|
|
202
|
+
expect(newHct.tone).toBeCloseTo(originalHct.tone, 1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should handle edge cases in round-trip", () => {
|
|
206
|
+
const testCases = [
|
|
207
|
+
{ c: 0, h: 0, t: 0 }, // Black
|
|
208
|
+
{ c: 0, h: 0, t: 100 }, // White
|
|
209
|
+
{ c: 0, h: 0, t: 50 }, // Gray
|
|
210
|
+
{ c: 100, h: 0, t: 50 }, // High chroma red
|
|
211
|
+
{ c: 100, h: 120, t: 50 }, // High chroma green
|
|
212
|
+
{ c: 100, h: 240, t: 50 }, // High chroma blue
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
for (const testCase of testCases) {
|
|
216
|
+
const hct1 = Hct.from(testCase.h, testCase.c, testCase.t);
|
|
217
|
+
const argb = hct1.toInt();
|
|
218
|
+
const hct2 = Hct.fromInt(argb);
|
|
219
|
+
|
|
220
|
+
// Tone should be very close
|
|
221
|
+
expect(hct2.tone).toBeCloseTo(hct1.tone, 0);
|
|
222
|
+
|
|
223
|
+
// For non-grayscale, hue should be maintained
|
|
224
|
+
if (testCase.c > 0 && hct2.chroma > 1) {
|
|
225
|
+
const hueDiff = Math.abs(hct2.hue - hct1.hue);
|
|
226
|
+
const normalizedDiff = Math.min(hueDiff, 360 - hueDiff);
|
|
227
|
+
expect(normalizedDiff).toBeLessThan(5);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|