├── .editorconfig ├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── changelog.md ├── codecov.yml ├── conf ├── biome.json ├── eslint.config.mjs ├── tsconfig.base.json ├── tsconfig.type.json ├── tsconfig.vite.json ├── vite.config.test.ts ├── vite.config.ts └── vite.config.worker.ts ├── doc └── colors-2.jpg ├── makefile ├── package-lock.json ├── package.json ├── readme.md ├── security.md ├── src ├── color │ ├── Color.ts │ ├── FinalColor.ts │ ├── LeafGroup.ts │ └── RootGroup.ts ├── extract │ ├── cleanInputs.ts │ └── extractor.ts ├── extractColors.ts ├── global.d.ts ├── helpers.ts ├── sort │ ├── AverageGroup.ts │ ├── AverageManager.ts │ └── sortColors.ts ├── types │ ├── Color.ts │ ├── NodeImageData.ts │ └── Options.ts ├── worker.ts └── workerWrapper.ts └── tests ├── averageGroup.ts ├── averageManager.ts ├── browser.ts ├── cleanInput.ts ├── color.ts ├── extractor.ts ├── extractorColorsCjs.ts ├── finalColors.ts ├── leafGroup.ts ├── namide-world.jpg ├── node.ts ├── rootGroup.ts └── sortColors.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@master 14 | with: 15 | persist-credentials: false 16 | - name: Install 17 | run: npm ci 18 | - name: Lint 19 | run: npm run lint 20 | - name: Build 21 | run: npm run build 22 | 23 | test: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@master 27 | - name: Install 28 | run: npm install 29 | - name: Test 30 | run: npm run coverage 31 | - uses: codecov/codecov-action@v1 32 | with: 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | directory: ./coverage 35 | file: ./coverage/clover.xml 36 | flags: unittests 37 | verbose: true 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | 4 | # Jest 5 | coverage 6 | junit.xml 7 | 8 | # Parcel 9 | .parcel-cache 10 | 11 | # Jest 12 | .cache 13 | 14 | # Build 15 | lib 16 | dist-doc 17 | extract-colors 18 | dist 19 | 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | pnpm-debug.log* 27 | lerna-debug.log* 28 | 29 | *.local 30 | 31 | # Editor directories and files 32 | .vscode/* 33 | !.vscode/extensions.json 34 | .idea 35 | .DS_Store 36 | *.suo 37 | *.ntvs* 38 | *.njsproj 39 | *.sln 40 | *.sw? 41 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | 4 | # Jest 5 | coverage 6 | junit.xml 7 | 8 | # Parcel 9 | .parcel-cache 10 | 11 | # Jest 12 | .cache 13 | 14 | # Build 15 | .github 16 | conf 17 | tests 18 | website 19 | .editorconfig 20 | .eslintrc.cjs 21 | codecov.yml 22 | makefile 23 | examples 24 | 25 | # Logs 26 | logs 27 | *.log 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | pnpm-debug.log* 32 | lerna-debug.log* 33 | 34 | *.local 35 | 36 | # Editor directories and files 37 | .vscode/* 38 | !.vscode/extensions.json 39 | .idea 40 | .DS_Store 41 | *.suo 42 | *.ntvs* 43 | *.njsproj 44 | *.sln 45 | *.sw? 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025-present Damien Doussaud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## v4.0.2 _(2023-09-06)_ 5 | 6 | - Improve bundle size 7 | 8 | 9 | ## v4.0.1 _(2023-09-05)_ 10 | 11 | - Update filename version for better node.js module usage 12 | 13 | 14 | ## v4.0.0 _(2023-09-03)_ 15 | 16 | - Remove errors and warnings from production build 17 | - Fix color groups keys 18 | - Optimize tree performances 19 | - Remove splitPower option 20 | - Change default values 21 | - Fix area values 22 | 23 | 24 | ## v3.0.0 _(2023-04-13)_ 25 | 26 | - Remove errors and warnings from production build 27 | - Fix color groups keys 28 | - Optimize tree performances 29 | - Remove `splitPower` option 30 | - Change default values 31 | - Fix area values 32 | 33 | 34 | ## v2.0.6 _(2023-04-06)_ 35 | 36 | - Fix medium key overlap 37 | 38 | 39 | ## v2.0.5 _(2023-02-07)_ 40 | 41 | - Remove default import from doc 42 | 43 | 44 | ## v2.0.4 _(2023-02-07)_ 45 | 46 | - Improve bundle size 47 | 48 | 49 | ## v2.0.3 _(2023-12-04)_ 50 | 51 | - Rewriting in Typescript 52 | - Add HSL conversion to sort and filter finals colors with new arguments (saturationDistance, lightnessDistance, hueDistance) 53 | - Remove canvas dependency to NodeJs version 54 | - Remove src and Image inputs from NodeJs version (only use ImageData) 55 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | 9 | parsers: 10 | gcov: 11 | branch_detection: 12 | conditional: yes 13 | loop: yes 14 | method: no 15 | macro: no 16 | 17 | comment: 18 | layout: "reach,diff,flags,files,footer" 19 | behavior: default 20 | require_changes: no 21 | -------------------------------------------------------------------------------- /conf/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /conf/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import eslintConfigPrettier from "eslint-config-prettier"; 4 | 5 | export default tseslint.config( 6 | eslint.configs.recommended, 7 | ...tseslint.configs.strict, 8 | ...tseslint.configs.stylistic, 9 | { 10 | ignores: ["lib/*"], 11 | }, 12 | eslintConfigPrettier 13 | ); 14 | -------------------------------------------------------------------------------- /conf/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitReturns": true, 12 | "skipLibCheck": true, 13 | "declaration": true, 14 | "outDir": "../lib" 15 | }, 16 | "include": ["../src/global.d.ts"], 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /conf/tsconfig.type.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"], 5 | "allowJs": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "isolatedModules": false, 9 | "types": ["vite/client"] 10 | }, 11 | "files": ["../src/extractColors.ts", "../src/workerWrapper.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /conf/tsconfig.vite.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"], 5 | "noEmit": true 6 | }, 7 | "files": ["../src/extractColors.ts", "../src/workerWrapper.ts"], 8 | "include": ["../src/**/*", "../tests/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /conf/vite.config.test.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | define: { 5 | __DEV__: "true", 6 | __BROWSER__: "true", 7 | }, 8 | test: { 9 | include: ["tests/*.ts"], 10 | environment: "node", 11 | coverage: { 12 | provider: "istanbul", // or 'v8' 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /conf/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { UserConfig } from "vite"; 3 | 4 | export default { 5 | define: { 6 | // __DEV__: JSON.stringify(`process.env.NODE_ENV !== "production"`) 7 | }, 8 | build: { 9 | sourcemap: true, 10 | lib: { 11 | entry: path.resolve(__dirname, "../src/extractColors.ts"), 12 | name: "ExtractColors", 13 | fileName: (format) => 14 | `extract-colors.${format === "es" ? "mjs" : format}`, 15 | formats: ["cjs", "es"], 16 | }, 17 | minify: "terser", 18 | terserOptions: { 19 | mangle: { 20 | properties: { 21 | reserved: [ 22 | "pixels", 23 | "distance", 24 | "colorValidator", 25 | "hueDistance", 26 | "saturationDistance", 27 | "lightnessDistance", 28 | "crossOrigin", 29 | "requestMode", 30 | "hex", 31 | "red", 32 | "green", 33 | "blue", 34 | "area", 35 | "hue", 36 | "saturation", 37 | "lightness", 38 | "intensity", 39 | "extractColors", 40 | "extractColorsFromImage", 41 | "extractColorsFromImageData", 42 | "extractColorsFromImageBitmap", 43 | "extractColorsFromSrc", 44 | ], 45 | }, 46 | }, 47 | }, 48 | rollupOptions: { 49 | output: { 50 | dir: "./lib", 51 | }, 52 | }, 53 | }, 54 | } satisfies UserConfig; 55 | -------------------------------------------------------------------------------- /conf/vite.config.worker.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { UserConfig } from "vite"; 3 | import conf from "./vite.config"; 4 | 5 | export default { 6 | ...conf, 7 | // define: { 8 | // __DEV__: JSON.stringify(`process.env.NODE_ENV !== "production"`), 9 | // }, 10 | build: { 11 | ...conf.build, 12 | emptyOutDir: false, 13 | lib: { 14 | entry: path.resolve(__dirname, "../src/workerWrapper.ts"), 15 | name: "ExtractColors", 16 | fileName: (format) => 17 | `worker-wrapper.${format === "es" ? "mjs" : format}`, 18 | formats: ["cjs", "es"], 19 | }, 20 | }, 21 | } satisfies UserConfig; 22 | -------------------------------------------------------------------------------- /doc/colors-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Namide/extract-colors/673f66775e7abdf1d69d205d91a090ae5f7fec3e/doc/colors-2.jpg -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | install: 2 | docker run -ti --rm \ 3 | -u "node" \ 4 | -v $(shell pwd):/usr/src/app \ 5 | -w /usr/src/app \ 6 | node:slim \ 7 | npm install 8 | 9 | code: 10 | docker run -ti --rm \ 11 | -u "node" \ 12 | -v $(shell pwd):/usr/src/app/extract-colors \ 13 | -w /usr/src/app/extract-colors \ 14 | -p 3001\:3001 \ 15 | -u "node" \ 16 | node:slim \ 17 | bash 18 | 19 | rootcode: 20 | docker run -ti --rm \ 21 | -u "node" \ 22 | -v $(shell pwd):/usr/src/app/extract-colors \ 23 | -w /usr/src/app/extract-colors \ 24 | -p 3001\:3001 \ 25 | -u "root" \ 26 | node:slim \ 27 | bash 28 | 29 | build: 30 | docker run -ti --rm \ 31 | -u "node" \ 32 | -v $(shell pwd):/usr/src/app \ 33 | -w /usr/src/app \ 34 | -u "node" \ 35 | node:slim \ 36 | npm run build 37 | 38 | pack: 39 | docker run -ti --rm \ 40 | -u "node" \ 41 | -v $(shell pwd):/usr/src/app \ 42 | -w /usr/src/app \ 43 | -u "node" \ 44 | node:slim \ 45 | npm pack 46 | 47 | lint: 48 | docker run -ti --rm \ 49 | -u "node" \ 50 | -v $(shell pwd):/usr/src/app \ 51 | -w /usr/src/app \ 52 | -u "node" \ 53 | node:slim \ 54 | npm run lint-fix 55 | 56 | test: 57 | docker run -ti --rm \ 58 | -u "node" \ 59 | -v $(shell pwd):/usr/src/app \ 60 | -w /usr/src/app \ 61 | -u "node" \ 62 | node:slim \ 63 | npm run test 64 | 65 | cov: 66 | docker run -ti --rm \ 67 | -u "node" \ 68 | -v $(shell pwd):/usr/src/app \ 69 | -w /usr/src/app \ 70 | -u "node" \ 71 | node:slim \ 72 | npm run coverage 73 | 74 | publish: 75 | $(MAKE) lint 76 | $(MAKE) cov 77 | $(MAKE) build 78 | docker run -ti --rm \ 79 | -u "node" \ 80 | -v $(shell pwd):/usr/src/app \ 81 | -w /usr/src/app \ 82 | -u "node" \ 83 | node:slim \ 84 | bash -c "npm adduser; npm publish" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extract-colors", 3 | "version": "4.2.0", 4 | "description": "Extract color palettes from images", 5 | "main": "lib/extract-colors.cjs", 6 | "module": "lib/extract-colors.mjs", 7 | "types": "lib/extract-colors.d.ts", 8 | "scripts": { 9 | "prebuild": "rm -rf ./lib", 10 | "build": "vite build --config conf/vite.config.ts && vite build --config conf/vite.config.worker.ts && tsc --project conf/tsconfig.type.json", 11 | "postbuild": "mv lib/extractColors.d.ts lib/extract-colors.d.ts; mv lib/workerWrapper.d.ts lib/worker-wrapper.d.ts; sed -i 's/__DEV__/process.env.NODE_ENV !== \"production\"/g' lib/**.*js ; ", 12 | "lint": "npx eslint --config conf/eslint.config.mjs", 13 | "lint-fix": "npx eslint --fix --config conf/eslint.config.mjs", 14 | "pretest": "npm run build", 15 | "test": "vitest --config conf/vite.config.test.ts", 16 | "precoverage": "npm run build", 17 | "coverage": "vitest run --coverage --config conf/vite.config.test.ts", 18 | "loop": "for file in lib/*.js; do terser $file --compress --mangle --mangle-props --source-map includeSources --output $file; done" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/Namide/extract-colors.git" 23 | }, 24 | "keywords": [ 25 | "color", 26 | "tool", 27 | "image", 28 | "extract", 29 | "palette", 30 | "browser", 31 | "rgb", 32 | "front-end", 33 | "back-end", 34 | "node", 35 | "hsl", 36 | "web workers" 37 | ], 38 | "author": "damien@doussaud.fr", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/Namide/extract-colors/issues" 42 | }, 43 | "homepage": "https://extract-colors.namide.com", 44 | "files": [ 45 | "lib" 46 | ], 47 | "devDependencies": { 48 | "@eslint/js": "^9.9.1", 49 | "@types/eslint__js": "^8.42.3", 50 | "@vitest/coverage-istanbul": "^2.0.5", 51 | "eslint": "^9.9.1", 52 | "eslint-config-prettier": "^9.1.0", 53 | "terser": "^5.31.6", 54 | "typescript": "^5.5.4", 55 | "typescript-eslint": "^8.4.0", 56 | "vite": "^5.4.2", 57 | "vitest": "^2.0.5" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Extract Colors 2 | 3 | [![package version](https://badge.fury.io/js/extract-colors.svg)](https://www.npmjs.com/package/extract-colors) 4 | [![npm min bundle size](https://img.shields.io/bundlephobia/min/extract-colors?style=flat&color=brightgreen)](https://bundlephobia.com/result?p=extract-colors) 5 | [![npm gzip bundle size](https://img.shields.io/bundlephobia/minzip/extract-colors?style=flat&color=brightgreen)](https://bundlephobia.com/result?p=extract-colors) 6 | [![zero dependency](https://img.shields.io/badge/dependency-zero-brightgreen)](https://www.npmjs.com/package/extract-colors?activeTab=dependencies) 7 | [![CI](https://github.com/Namide/extract-colors/workflows/CI/badge.svg)](https://github.com/Namide/extract-colors/actions) 8 | [![code coverage](https://codecov.io/gh/Namide/extract-colors/branch/master/graph/badge.svg?token=80PUQ24PW5)](https://codecov.io/gh/Namide/extract-colors) 9 | [![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](#license) 10 | [![Downloaded](https://img.shields.io/npm/dt/extract-colors)](https://www.npmjs.com/package/extract-colors) 11 | 12 | Extract color palettes from images. 13 | Simple use, < 6kB minified, gzip ≈ 2kB, fast process and no dependencies for browser. 14 | Need image reader dependence for node.js 15 | 16 | [Website](https://extract-colors.namide.com/) | [Demo](https://extract-colors.namide.com/demo) | [Guide](https://extract-colors.namide.com/guide) 17 | 18 | ![3 examples of colors extraction](./doc/colors-2.jpg) 19 | 20 | ## Requirements 21 | 22 | ### Browsers 23 | 24 | - Firefox: 29+ 25 | - Chrome: 33+ 26 | - Edge: 12+ 27 | - Opera: 19+ 28 | - Safari: 8+ 29 | - Webview Android: 4.4.3+ 30 | - Samsung Internet: 2.0+ 31 | - ~~Internet Explorer~~ 32 | 33 | ### Node 34 | 35 | - Node.js: 6.0+ 36 | 37 | ## Install 38 | 39 | ### For browser 40 | 41 | ```bash 42 | npm install --save extract-colors 43 | ``` 44 | 45 | ### For node.js 46 | 47 | Need to install an ImageData extractor like `get-pixels` 48 | 49 | ```bash 50 | npm install --save extract-colors get-pixels 51 | ``` 52 | 53 | ## Usage 54 | 55 | ### Browser example 56 | 57 | ```js 58 | import { extractColors } from "extract-colors"; 59 | 60 | const src = "my-image.jpg"; 61 | 62 | extractColors(src).then(console.log).catch(console.error); 63 | ``` 64 | 65 | > You can use different types for `src` param (`String` for a path of image, `HTMLImageElement` or `ImageData`). 66 | 67 | ### Node.js example 68 | 69 | ```js 70 | const path = require("path"); 71 | const getPixels = require("get-pixels"); 72 | const { extractColors } = require("extract-colors"); 73 | 74 | const src = path.join(__dirname, "./my-image.jpg"); 75 | 76 | getPixels(src, (err, pixels) => { 77 | if (!err) { 78 | const data = [...pixels.data]; 79 | const [width, height] = pixels.shape; 80 | 81 | extractColors({ data, width, height }).then(console.log).catch(console.log); 82 | } 83 | }); 84 | ``` 85 | 86 | > This example use `get-pixels` but you can change the lib. 87 | > Just send the ImageData object to `extractColors(imageData)`. 88 | 89 | ### ExtractorOptions 90 | 91 | ```js 92 | const options = { 93 | pixels: 64000, 94 | distance: 0.22, 95 | colorValidator: (red, green, blue, alpha = 255) => alpha > 250, 96 | saturationDistance: 0.2, 97 | lightnessDistance: 0.2, 98 | hueDistance: 0.083333333, 99 | }; 100 | 101 | extractColors(src, options).then(console.log).catch(console.error); 102 | ``` 103 | 104 | **pixels** 105 | _Total pixel number of the resized picture for calculation_ 106 | Type: `Integer` 107 | Default: `64000` 108 | 109 | **distance** 110 | _From 0 to 1 is the color distance to not have near colors (1 distance is between white and black)_ 111 | Type: `Number` 112 | Default: `0.22` 113 | 114 | **colorValidator** 115 | _Test function to enable only some colors_ 116 | Type: `Function` 117 | Default: `(red, green, blue, alpha = 255) => alpha > 250` 118 | 119 | **crossOrigin** 120 | _Only for browser, can be 'Anonymous' to avoid client side CORS_ 121 | _(the server side images need authorizations too)_ 122 | Type: `String` 123 | Default: `""` 124 | 125 | **requestMode** 126 | _Only for Web Workers in browser: it's used to determine if cross-origin requests lead to valid responses, and which properties of the response are readable_ 127 | Type: `String` 128 | Default: `cors` 129 | 130 | **saturationDistance** 131 | _Minimum saturation value between two colors otherwise the colors will be merged (from 0 to 1)_ 132 | Type: `String` 133 | Default: `0.2` 134 | 135 | **lightnessDistance** 136 | _Minimum lightness value between two colors otherwise the colors will be merged (from 0 to 1)_ 137 | Type: `String` 138 | Default: `0.2` 139 | 140 | **hueDistance** 141 | _Minimum hue value between two colors otherwise the colors will be merged (from 0 to 1)_ 142 | Type: `String` 143 | Default: `0.083333333` 144 | 145 | ## Return of the promise 146 | 147 | Array of colors with the followed properties: 148 | 149 | ```js 150 | [ 151 | { 152 | hex: "#858409",​​ 153 | red: 133,​​ 154 | green: 132,​​ 155 | blue: 9,​​ 156 | hue: 0.16532258064516128,​​ 157 | intensity: 0.4862745098039216,​​ 158 | lightness: 0.2784313725490196,​​ 159 | saturation: 0.8732394366197184, 160 | area: 0.0004 161 | }, 162 | ... 163 | ] 164 | ``` 165 | 166 | | Field | Example | Type | Description | 167 | | ---------- | ------- | ------- | --------------------------------------------------------- | 168 | | hex | #858409 | String | color in hexadecimal string | 169 | | red | 133 | Integer | red canal from 0 to 255 | 170 | | green | 132 | Integer | green canal from 0 to 255 | 171 | | blue | 9 | Integer | blue canal from 0 to 255 | 172 | | hue | 0.1653 | Number | color tone from 0 to 1 | 173 | | intensity | 0.4862 | Number | color intensity from 0 to 1 | 174 | | lightness | 0.2784 | Number | color lightness from 0 to 1 | 175 | | saturation | 0.8732 | Number | color saturation from 0 to 1 | 176 | | area | 0.0004 | Number | area of the color and his neighbouring colors from 0 to 1 | 177 | 178 | ## License 179 | 180 | [MIT](https://opensource.org/licenses/MIT) 181 | 182 | Copyright (c) 2025-present, Damien Doussaud 183 | -------------------------------------------------------------------------------- /security.md: -------------------------------------------------------------------------------- 1 | # Reporting a Bug 2 | 3 | ![GitHub Issues open](https://img.shields.io/github/issues-raw/Namide/extract-colors) 4 | ![GitHub Issues closed](https://img.shields.io/github/issues-closed-raw/Namide/extract-colors) 5 | 6 | To report a bug, please create an issue on [github.com/Namide/extract-colors/issues/new](https://github.com/Namide/extract-colors/issues/new). 7 | 8 | I recommend always using the latest versions of extract-colors to ensure your application benefits from the lastest updates and patches. 9 | -------------------------------------------------------------------------------- /src/color/Color.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Informations like saturation or count of pixels in image. 3 | * 4 | * @class 5 | * @classdesc Calculate some informations and store data about color. 6 | */ 7 | export default class Color { 8 | _red: number; 9 | _green: number; 10 | _blue: number; 11 | _hex: number; 12 | _count = 1; 13 | 14 | private __saturation = -1; 15 | private __hue = -1; 16 | private __lightness = -1; 17 | private __intensity = -1; 18 | 19 | /** 20 | * Set red, green and blue colors to create the Color object. 21 | */ 22 | constructor( 23 | red: number, 24 | green: number, 25 | blue: number, 26 | hex = (red << 16) | (green << 8) | blue 27 | ) { 28 | this._red = red; 29 | this._green = green; 30 | this._blue = blue; 31 | this._hex = hex; 32 | } 33 | 34 | /** 35 | * Distance between two colors. 36 | * - Minimum is 0 (between two same colors) 37 | * - Maximum is 1 (for example between black and white) 38 | */ 39 | static distance(colorA: Color, colorB: Color) { 40 | return ( 41 | (Math.abs(colorB._red - colorA._red) + 42 | Math.abs(colorB._green - colorA._green) + 43 | Math.abs(colorB._blue - colorA._blue)) / 44 | (3 * 0xff) 45 | ); 46 | } 47 | 48 | clone() { 49 | const color = new Color(this._red, this._green, this._blue, this._hex); 50 | color._count = this._count; 51 | return color; 52 | } 53 | 54 | updateHSL() { 55 | const red = this._red / 255; 56 | const green = this._green / 255; 57 | const blue = this._blue / 255; 58 | 59 | const max = Math.max(red, green, blue); 60 | const min = Math.min(red, green, blue); 61 | 62 | this.__lightness = (max + min) / 2; 63 | 64 | // achromatic 65 | if (max === min) { 66 | this.__hue = 0; 67 | this.__saturation = 0; 68 | this.__intensity = 0; 69 | } else { 70 | const distance = max - min; 71 | 72 | this.__saturation = 73 | this.__lightness > 0.5 74 | ? distance / (2 - max - min) 75 | : distance / (max + min); 76 | this.__intensity = 77 | this.__saturation * ((0.5 - Math.abs(0.5 - this.__lightness)) * 2); 78 | switch (max) { 79 | case red: 80 | this.__hue = ((green - blue) / distance + (green < blue ? 6 : 0)) / 6; 81 | break; 82 | case green: 83 | this.__hue = ((blue - red) / distance + 2) / 6; 84 | break; 85 | case blue: 86 | this.__hue = ((red - green) / distance + 4) / 6; 87 | break; 88 | } 89 | } 90 | } 91 | 92 | /** 93 | * Hue from 0 to 1 94 | */ 95 | get _hue() { 96 | if (this.__hue === -1) { 97 | this.updateHSL(); 98 | } 99 | return this.__hue; 100 | } 101 | 102 | /** 103 | * Saturation from 0 to 1 104 | */ 105 | get _saturation() { 106 | if (this.__saturation === -1) { 107 | this.updateHSL(); 108 | } 109 | return this.__saturation; 110 | } 111 | 112 | /** 113 | * Lightness from 0 to 1 114 | */ 115 | get _lightness() { 116 | if (this.__lightness === -1) { 117 | this.updateHSL(); 118 | } 119 | return this.__lightness; 120 | } 121 | 122 | /** 123 | * Color intensity from 0 to 1 124 | */ 125 | get _intensity() { 126 | if (this.__intensity === -1) { 127 | this.updateHSL(); 128 | } 129 | return this.__intensity; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/color/FinalColor.ts: -------------------------------------------------------------------------------- 1 | import { FinalColor } from "../types/Color"; 2 | import Color from "./Color"; 3 | 4 | /** 5 | * Normalize color 6 | * 7 | * @param color Initial color 8 | * @param pixels Pixel count of this color 9 | * 10 | * @returns Normalized color 11 | */ 12 | export const createFinalColor = (color: Color, pixels: number): FinalColor => { 13 | return { 14 | hex: `#${"0".repeat( 15 | 6 - color._hex.toString(16).length 16 | )}${color._hex.toString(16)}`, 17 | red: color._red, 18 | green: color._green, 19 | blue: color._blue, 20 | area: color._count / pixels, 21 | hue: color._hue, 22 | saturation: color._saturation, 23 | lightness: color._lightness, 24 | intensity: color._intensity, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/color/LeafGroup.ts: -------------------------------------------------------------------------------- 1 | import Color from "./Color"; 2 | 3 | /** 4 | * Manage list of colors to optimize and merge neighbors colors. 5 | * 6 | * @export 7 | * @class LeafGroup 8 | */ 9 | export default class LeafGroup { 10 | _count: number; 11 | _children: Record; 12 | 13 | /** 14 | * Store colors or groups and _count similiar groups in the image. 15 | */ 16 | constructor() { 17 | this._count = 0; 18 | this._children = {}; 19 | } 20 | 21 | /** 22 | * Add color to the group. 23 | * 24 | * @param _hex Hexadecimal value of the color 25 | * @param _red Red chanel amount of the color 26 | * @param _green Green chanel amount of the color 27 | * @param _blue Blue chanel amount of the color 28 | * @returns The color 29 | */ 30 | addColor(_hex: number, _red: number, _green: number, _blue: number) { 31 | this._count++; 32 | if (this._children[_hex]) { 33 | this._children[_hex]._count++; 34 | } else { 35 | this._children[_hex] = new Color(_red, _green, _blue, _hex); 36 | } 37 | return this._children[_hex]; 38 | } 39 | 40 | /** 41 | * Get list of groups of list of colors. 42 | * 43 | * @returns List of colors 44 | */ 45 | getList() { 46 | return (Object.keys(this._children) as unknown[] as number[]).map( 47 | (key) => this._children[key] 48 | ); 49 | } 50 | 51 | /** 52 | * Representative color of leaf. 53 | * 54 | * @returns Main color of the leaf 55 | */ 56 | createMainColor() { 57 | const list = this.getList(); 58 | const biggest = list.reduce((a, b) => (a._count >= b._count ? a : b)); 59 | const main = biggest.clone(); 60 | main._count = this._count; 61 | return main; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/color/RootGroup.ts: -------------------------------------------------------------------------------- 1 | import Color from "./Color"; 2 | import LeafGroup from "./LeafGroup"; 3 | 4 | /** 5 | * RootGroup colors with algorithms to optimize and merge neighbors colors. 6 | * 7 | * @class 8 | * @classdesc Manage list of colors or groups. 9 | */ 10 | export default class RootGroup { 11 | _count: number; 12 | _children: Record; 13 | 14 | /** 15 | * Store colors or groups and _count similiar groups in the image. 16 | */ 17 | constructor() { 18 | this._count = 0; 19 | this._children = {}; 20 | } 21 | 22 | /** 23 | * Get list of groups of list of colors. 24 | */ 25 | getList() { 26 | return (Object.keys(this._children) as unknown[] as number[]).map( 27 | (key) => this._children[key] 28 | ); 29 | } 30 | 31 | addColor(r: number, g: number, b: number) { 32 | const full = (r << 16) | (g << 8) | b; 33 | const loss = 34 | (((r >> 4) & 0xf) << 8) | (((g >> 4) & 0xf) << 4) | ((b >> 4) & 0xf); 35 | this._count++; 36 | return this.getLeafGroup(loss).addColor(full, r, g, b); 37 | } 38 | 39 | /** 40 | * Add a key for a color, this key is a simplification to find neighboring colors. 41 | * Neighboring colors has same key. 42 | */ 43 | getLeafGroup(key: number) { 44 | if (!this._children[key]) { 45 | this._children[key] = new LeafGroup(); 46 | } 47 | return this._children[key] as LeafGroup; 48 | } 49 | 50 | /** 51 | * List of colors sorted by importance (neighboring hare calculated by distance and removed). 52 | * Importance is calculated with the saturation and _count of neighboring colors. 53 | */ 54 | getColors(_distance: number) { 55 | const list = this.getList().map((child) => child.createMainColor()); 56 | 57 | list.sort((a, b) => b._count - a._count); 58 | 59 | const newList: Color[] = []; 60 | while (list.length) { 61 | const current = list.shift() as Color; 62 | list 63 | .filter((color) => Color.distance(current, color) < _distance) 64 | .forEach((near) => { 65 | current._count += near._count; 66 | const i = list.findIndex((color) => color === near); 67 | list.splice(i, 1); 68 | }); 69 | 70 | newList.push(current); 71 | } 72 | 73 | return newList; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/extract/cleanInputs.ts: -------------------------------------------------------------------------------- 1 | import { BrowserOptions, OptionsCleaned } from "../types/Options"; 2 | 3 | /** 4 | * Default extractor values 5 | */ 6 | export const EXTRACTOR_PIXELS_DEFAULT = 64000; 7 | export const EXTRACTOR_DISTANCE_DEFAULT = 0.22; 8 | 9 | /** 10 | * Default average values 11 | */ 12 | export const AVERAGE_HUE_DEFAULT = 1 / 12; 13 | export const AVERAGE_SATURATION_DEFAULT = 1 / 5; 14 | export const AVERAGE_LIGHTNESS_DEFAULT = 1 / 5; 15 | 16 | export function testInputs({ 17 | pixels = EXTRACTOR_PIXELS_DEFAULT, 18 | distance = EXTRACTOR_DISTANCE_DEFAULT, 19 | colorValidator = ( 20 | _red: number, 21 | _green: number, 22 | _blue: number, 23 | _alpha?: number 24 | ) => (_alpha ?? 255) > 250, 25 | hueDistance = AVERAGE_HUE_DEFAULT, 26 | saturationDistance = AVERAGE_LIGHTNESS_DEFAULT, 27 | lightnessDistance = AVERAGE_SATURATION_DEFAULT, 28 | crossOrigin = "", 29 | requestMode = "cors", 30 | }: BrowserOptions = {}) { 31 | /** 32 | * Test if value is an integer. 33 | */ 34 | const testUint = ( 35 | label: string, 36 | val: number, 37 | min = 0, 38 | max = Number.MAX_SAFE_INTEGER 39 | ) => { 40 | if (!Number.isInteger(val)) { 41 | throw new Error(`${label} is not a valid number (${val})`); 42 | } 43 | 44 | if (val < min) { 45 | console.warn(`${label} can not be less than ${min} (it's ${val})`); 46 | } 47 | 48 | if (val > max) { 49 | console.warn(`${label} can not be more than ${max} (it's ${val})`); 50 | } 51 | 52 | return Math.min(Math.max(val, min), max); 53 | }; 54 | 55 | /** 56 | * Test if value is a number. 57 | */ 58 | const testNumber = ( 59 | label: string, 60 | val: number, 61 | min = 0, 62 | max = Number.MAX_VALUE 63 | ) => { 64 | if (Number(val) !== val) { 65 | throw new Error(`${label} is not a valid number (${val})`); 66 | } 67 | 68 | if (val < min) { 69 | console.warn(`${label} can not be less than ${min} (it's ${val})`); 70 | } 71 | 72 | if (val > max) { 73 | console.warn(`${label} can not be more than ${max} (it's ${val})`); 74 | } 75 | 76 | return Math.min(Math.max(val, min), max); 77 | }; 78 | 79 | /** 80 | * Test if value is a function. 81 | */ 82 | const testFunction = void>(label: string, val: T) => { 83 | if (!val || {}.toString.call(val) !== "[object Function]") { 84 | throw new Error(`${label} is not a function (${val})`); 85 | } 86 | 87 | return val; 88 | }; 89 | 90 | /** 91 | * Test if value is in the list of values 92 | */ 93 | const testValueInList = (label: string, val: T, list: T[]) => { 94 | if (list.indexOf(val) < 0) { 95 | console.warn( 96 | `${label} can be one of this values ${list 97 | .map((v) => `"${v}"`) 98 | .join(", ")} (it's "${val}")` 99 | ); 100 | } 101 | }; 102 | 103 | testUint("pixels", pixels || 0, 1); 104 | testNumber("distance", distance, 0, 1); 105 | testFunction("colorValidator", colorValidator); 106 | testNumber("hueDistance", hueDistance, 0, 1); 107 | testNumber("saturationDistance", saturationDistance, 0, 1); 108 | testNumber("lightnessDistance", lightnessDistance, 0, 1); 109 | testValueInList("crossOrigin", crossOrigin, [ 110 | "", 111 | "anonymous", 112 | "use-credentials", 113 | ]); 114 | testValueInList("requestMode", requestMode, [ 115 | "cors", 116 | "navigate", 117 | "no-cors", 118 | "same-origin", 119 | ]); 120 | } 121 | 122 | export default ({ 123 | pixels = EXTRACTOR_PIXELS_DEFAULT, 124 | distance = EXTRACTOR_DISTANCE_DEFAULT, 125 | colorValidator = ( 126 | _red: number, 127 | _green: number, 128 | _blue: number, 129 | _alpha?: number 130 | ) => (_alpha ?? 255) > 250, 131 | hueDistance = AVERAGE_HUE_DEFAULT, 132 | saturationDistance = AVERAGE_LIGHTNESS_DEFAULT, 133 | lightnessDistance = AVERAGE_SATURATION_DEFAULT, 134 | crossOrigin = "", 135 | requestMode = "cors", 136 | }: BrowserOptions = {}): OptionsCleaned => { 137 | return [ 138 | Math.max(pixels, 1), 139 | Math.min(Math.max(distance, 0), 1), 140 | colorValidator, 141 | Math.min(Math.max(hueDistance, 0), 1), 142 | Math.min(Math.max(saturationDistance, 0), 1), 143 | Math.min(Math.max(lightnessDistance, 0), 1), 144 | crossOrigin, 145 | requestMode, 146 | ]; 147 | }; 148 | -------------------------------------------------------------------------------- /src/extract/extractor.ts: -------------------------------------------------------------------------------- 1 | import RootGroup from "../color/RootGroup"; 2 | 3 | /** 4 | * Run extract process and get list of colors. 5 | */ 6 | export default ( 7 | { 8 | data, 9 | width, 10 | height, 11 | }: 12 | | ImageData 13 | | { data: Uint8ClampedArray | number[]; width?: number; height?: number }, 14 | _pixels: number, 15 | _distance: number, 16 | _colorValidator: ( 17 | red: number, 18 | green: number, 19 | blue: number, 20 | alpha: number 21 | ) => boolean 22 | ) => { 23 | const colorGroup = new RootGroup(); 24 | const reducer = 25 | width && height ? Math.floor((width * height) / _pixels) || 1 : 1; 26 | let ignoredColorsCount = 0; 27 | 28 | for (let i = 0; i < data.length; i += 4 * reducer) { 29 | const r = data[i]; // 0 -> 255 30 | const g = data[i + 1]; 31 | const b = data[i + 2]; 32 | const a = data[i + 3]; 33 | 34 | if (_colorValidator(r, g, b, a)) { 35 | colorGroup.addColor(r, g, b); 36 | } else { 37 | ignoredColorsCount++; 38 | } 39 | } 40 | 41 | return { 42 | colors: colorGroup.getColors(_distance), 43 | count: colorGroup._count + ignoredColorsCount, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/extractColors.ts: -------------------------------------------------------------------------------- 1 | import cleanInputs, { testInputs } from "./extract/cleanInputs"; 2 | import extractor from "./extract/extractor"; 3 | import { 4 | checkIsBrowser, 5 | checkIsNode, 6 | checkIsWorker, 7 | extractImageData, 8 | sortFinalColors, 9 | } from "./helpers"; 10 | import { FinalColor } from "./types/Color"; 11 | import { BrowserOptions, ImageDataAlt, NodeOptions } from "./types/Options"; 12 | 13 | /** 14 | * Extract colors from an ImageData object. 15 | * 16 | * @param imageData Data of the image 17 | * @param options Process configuration 18 | * @param options.pixels Total pixel number of the resized picture for calculation 19 | * @param options.distance From 0 to 1 is the color distance to not have near colors (1 distance is between white and black) 20 | * @param options.colorValidator Test function to enable only some colors 21 | * @param options.saturationDistance Minimum saturation value between two colors otherwise the colors will be merged (from 0 to 1) 22 | * @param options.lightnessDistance inimum lightness value between two colors otherwise the colors will be merged (from 0 to 1) 23 | * @param options.hueDistance inimum hue value between two colors otherwise the colors will be merged (from 0 to 1) 24 | * @param options.crossOrigin support for CORS (only for browser) 25 | * @param options.requestMode support for CORS (only for Web Workers in browser) 26 | * 27 | * @returns List of extracted colors 28 | */ 29 | export const extractColorsFromImageData = ( 30 | imageData: ImageData | ImageDataAlt, 31 | options: NodeOptions | BrowserOptions = {} 32 | ) => { 33 | if (__DEV__) { 34 | testInputs(options); 35 | } 36 | 37 | const [ 38 | _pixels, 39 | _distance, 40 | _colorValidator, 41 | _hueDistance, 42 | _saturationDistance, 43 | _lightnessDistance, 44 | ] = cleanInputs(options); 45 | const { colors, count } = extractor( 46 | imageData, 47 | _pixels, 48 | _distance, 49 | _colorValidator 50 | ); 51 | return sortFinalColors( 52 | colors, 53 | count, 54 | _hueDistance, 55 | _saturationDistance, 56 | _lightnessDistance 57 | ); 58 | }; 59 | 60 | /** 61 | * Extract colors from an HTMLImageElement. 62 | * Browser only 63 | * 64 | * @param image HTML image element 65 | * @param options Process configuration 66 | * @param options.pixels Total pixel number of the resized picture for calculation 67 | * @param options.distance From 0 to 1 is the color distance to not have near colors (1 distance is between white and black) 68 | * @param options.colorValidator Test function to enable only some colors 69 | * @param options.saturationDistance Minimum saturation value between two colors otherwise the colors will be merged (from 0 to 1) 70 | * @param options.lightnessDistance inimum lightness value between two colors otherwise the colors will be merged (from 0 to 1) 71 | * @param options.hueDistance inimum hue value between two colors otherwise the colors will be merged (from 0 to 1) 72 | * @param options.crossOrigin support for CORS (only for browser) 73 | * @param options.requestMode support for CORS (only for Web Workers in browser) 74 | * 75 | * @returns List of extracted colors 76 | */ 77 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 78 | // @ts-ignore 79 | export const extractColorsFromImage = async ( 80 | image: HTMLImageElement, 81 | options: BrowserOptions = {} 82 | ): Promise => { 83 | // Node.js version 84 | if (checkIsNode()) { 85 | if (__DEV__) { 86 | throw new Error( 87 | "Use extractColors instead extractColorsFromImage for Node.js" 88 | ); 89 | } 90 | return []; 91 | } 92 | 93 | if (__DEV__) { 94 | testInputs(options); 95 | } 96 | 97 | // Browser version 98 | const [ 99 | _pixels, 100 | _distance, 101 | _colorValidator, 102 | _hueDistance, 103 | _saturationDistance, 104 | _lightnessDistance, 105 | _crossOrigin, 106 | ] = cleanInputs(options); 107 | image.crossOrigin = _crossOrigin; 108 | return new Promise((resolve: (value: FinalColor[]) => void) => { 109 | const extract = (image: HTMLImageElement) => { 110 | const imageData = extractImageData(image, _pixels); 111 | const { colors, count } = extractor( 112 | imageData, 113 | _pixels, 114 | _distance, 115 | _colorValidator 116 | ); 117 | resolve( 118 | sortFinalColors( 119 | colors, 120 | count, 121 | _hueDistance, 122 | _saturationDistance, 123 | _lightnessDistance 124 | ) 125 | ); 126 | }; 127 | 128 | if (image.complete) { 129 | extract(image); 130 | } else { 131 | const imageLoaded = () => { 132 | image.removeEventListener("load", imageLoaded); 133 | extract(image); 134 | }; 135 | image.addEventListener("load", imageLoaded); 136 | } 137 | }); 138 | }; 139 | 140 | export const extractColorsFromImageBitmap = async ( 141 | image: ImageBitmap, 142 | options: BrowserOptions = {} 143 | ): Promise => { 144 | // Node.js version 145 | if (checkIsNode()) { 146 | if (__DEV__) { 147 | throw new Error( 148 | "Use extractColors instead extractColorsFromImageBitmap for Node.js" 149 | ); 150 | } 151 | return []; 152 | } 153 | 154 | if (__DEV__) { 155 | testInputs(options); 156 | } 157 | 158 | const [ 159 | _pixels, 160 | _distance, 161 | _colorValidator, 162 | _hueDistance, 163 | _saturationDistance, 164 | _lightnessDistance, 165 | ] = cleanInputs(options); 166 | 167 | const imageData = extractImageData(image, _pixels); 168 | const { colors, count } = extractor( 169 | imageData, 170 | _pixels, 171 | _distance, 172 | _colorValidator 173 | ); 174 | 175 | return sortFinalColors( 176 | colors, 177 | count, 178 | _hueDistance, 179 | _saturationDistance, 180 | _lightnessDistance 181 | ); 182 | }; 183 | 184 | /** 185 | * Extract colors from a path. 186 | * The image will be downloaded. 187 | * 188 | * @param src Image source 189 | * @param options Process configuration 190 | * @param options.pixels Total pixel number of the resized picture for calculation 191 | * @param options.distance From 0 to 1 is the color distance to not have near colors (1 distance is between white and black) 192 | * @param options.colorValidator Test function to enable only some colors 193 | * @param options.saturationDistance Minimum saturation value between two colors otherwise the colors will be merged (from 0 to 1) 194 | * @param options.lightnessDistance inimum lightness value between two colors otherwise the colors will be merged (from 0 to 1) 195 | * @param options.hueDistance inimum hue value between two colors otherwise the colors will be merged (from 0 to 1) 196 | * @param options.crossOrigin support for CORS (only for browser) 197 | * @param options.requestMode support for CORS (only for Web Workers in browser) 198 | * 199 | * @returns List of extracted colors 200 | */ 201 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 202 | // @ts-ignore 203 | export const extractColorsFromSrc = async ( 204 | src: string, 205 | options: BrowserOptions = {} 206 | ): Promise => { 207 | // Node.js version 208 | if (checkIsNode()) { 209 | if (__DEV__) { 210 | throw new Error("Can not use extractColorsFromSrc for Node.js"); 211 | } 212 | return []; 213 | } 214 | 215 | if (__DEV__) { 216 | testInputs(options); 217 | } 218 | 219 | // Web Worker version 220 | if (checkIsWorker()) { 221 | const inputs = cleanInputs(options); 222 | const response = await fetch(src, { mode: inputs[7] }); 223 | const blob = await response.blob(); 224 | const bitmap = await createImageBitmap(blob); 225 | const colors = await extractColorsFromImageBitmap(bitmap, options); 226 | bitmap.close(); 227 | return colors; 228 | } 229 | 230 | // Browser version 231 | const image = new Image(); 232 | image.src = src; 233 | return extractColorsFromImage(image, options); 234 | }; 235 | 236 | /** 237 | * Extract colors from a picture. 238 | * 239 | * @param picture Image, image source or image data (node.js context only support image data) 240 | * @param options Process configuration 241 | * @param options.pixels Total pixel number of the resized picture for calculation 242 | * @param options.distance From 0 to 1 is the color distance to not have near colors (1 distance is between white and black) 243 | * @param options.colorValidator Test function to enable only some colors 244 | * @param options.saturationDistance Minimum saturation value between two colors otherwise the colors will be merged (from 0 to 1) 245 | * @param options.lightnessDistance inimum lightness value between two colors otherwise the colors will be merged (from 0 to 1) 246 | * @param options.hueDistance inimum hue value between two colors otherwise the colors will be merged (from 0 to 1) 247 | * @param options.crossOrigin support for CORS (only for browser) 248 | * @param options.requestMode support for CORS (only for Web Workers in browser) 249 | * 250 | * @returns List of extracted colors 251 | */ 252 | export const extractColors = ( 253 | picture: string | HTMLImageElement | ImageData | ImageDataAlt, 254 | options?: BrowserOptions 255 | ) => { 256 | // Browser version 257 | if (checkIsBrowser()) { 258 | if (__DEV__) { 259 | if (options?.requestMode) { 260 | console.warn( 261 | "options.requestMode not supported in Browser, use options.crossOrigin instead" 262 | ); 263 | } 264 | } 265 | 266 | if (picture instanceof Image) { 267 | return extractColorsFromImage(picture, options); 268 | } 269 | 270 | if ( 271 | picture instanceof ImageData || 272 | (picture instanceof Object && picture.data) 273 | ) { 274 | return new Promise((resolve: (value: FinalColor[]) => void) => { 275 | resolve(extractColorsFromImageData(picture, options)); 276 | }); 277 | } 278 | 279 | if (typeof picture === "string") { 280 | return extractColorsFromSrc(picture, options); 281 | } 282 | } 283 | 284 | // Worker version 285 | if (checkIsWorker()) { 286 | if (__DEV__) { 287 | if (options?.crossOrigin) { 288 | console.warn( 289 | "options.crossOrigin not supported in Web Worker, use options.requestMode instead" 290 | ); 291 | } 292 | } 293 | 294 | if ( 295 | picture instanceof ImageData || 296 | (picture instanceof Object && (picture as ImageDataAlt).data) 297 | ) { 298 | return new Promise((resolve: (value: FinalColor[]) => void) => { 299 | resolve( 300 | extractColorsFromImageData( 301 | picture as ImageData | ImageDataAlt, 302 | options 303 | ) 304 | ); 305 | }); 306 | } 307 | 308 | if (typeof picture === "string") { 309 | return extractColorsFromSrc(picture, options); 310 | } 311 | 312 | // HTMLImageElement not enable on Worker, switch to src fallback 313 | if ((picture as HTMLImageElement).src) { 314 | if (__DEV__) { 315 | console.warn( 316 | "HTMLImageElement not enable on worker, a fallback is used to extract src from your HTMLImageElement, please send 'src' instead HTMLImageElement" 317 | ); 318 | } 319 | return extractColorsFromSrc((picture as HTMLImageElement).src, options); 320 | } 321 | } 322 | 323 | // Node.js version 324 | if (checkIsNode()) { 325 | if (__DEV__) { 326 | if (picture instanceof String) { 327 | throw new Error( 328 | "Send imageData to extractColors (Image src or HTMLImageElement not supported in Nodejs)" 329 | ); 330 | } 331 | 332 | if (!(picture as ImageData).data) { 333 | throw new Error("Send imageData to extractColors"); 334 | } 335 | 336 | if (options?.crossOrigin) { 337 | console.warn("options.crossOrigin not supported in Node.js"); 338 | } 339 | } 340 | 341 | return new Promise((resolve: (value: FinalColor[]) => void) => { 342 | resolve( 343 | extractColorsFromImageData(picture as ImageData | ImageDataAlt, options) 344 | ); 345 | }); 346 | } 347 | 348 | throw new Error(`Can not analyse picture`); 349 | }; 350 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean; 2 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import Color from "./color/Color"; 2 | import sortColors from "./sort/sortColors"; 3 | import { createFinalColor } from "./color/FinalColor"; 4 | 5 | /** 6 | * Browser context detection 7 | * 8 | * @returns Is a browser context 9 | */ 10 | export const checkIsBrowser = () => 11 | typeof window !== "undefined" && typeof window.document !== "undefined"; 12 | 13 | /** 14 | * Worker in Browser context detection 15 | * 16 | * @returns Is a worker browser context 17 | */ 18 | export const checkIsWorker = () => 19 | typeof self === "object" && 20 | self.constructor && 21 | self.constructor.name === "DedicatedWorkerGlobalScope"; 22 | 23 | /** 24 | * Node.js context detection 25 | * 26 | * @returns Is Node.js context 27 | */ 28 | export const checkIsNode = () => 29 | typeof window === "undefined" && 30 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 31 | // @ts-ignore 32 | typeof process !== "undefined" && 33 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 34 | // @ts-ignore 35 | process.versions != null && 36 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 37 | // @ts-ignore 38 | process.versions.node != null; 39 | 40 | /** 41 | * Sort colors and generate standard list of colors. 42 | * 43 | * @param _colors List of colors 44 | * @param _pixels Count of pixels in the image 45 | * @param _hueDistance Maximal HUE distance between pixel before pixel merging 46 | * @param _saturationDistance Maximal saturation distance between pixel before pixel merging 47 | * @param _lightnessDistance Maximal lightness distance between pixel before pixel merging 48 | * @returns Sorted colors list 49 | */ 50 | export const sortFinalColors = ( 51 | _colors: Color[], 52 | _pixels: number, 53 | _hueDistance: number, 54 | _saturationDistance: number, 55 | _lightnessDistance: number, 56 | ) => { 57 | const list = sortColors( 58 | _colors, 59 | _pixels, 60 | _hueDistance, 61 | _saturationDistance, 62 | _lightnessDistance, 63 | ); 64 | return list.map((color) => createFinalColor(color, _pixels)); 65 | }; 66 | 67 | /** 68 | * Extract ImageData from image. 69 | * Reduce image to a pixel count. 70 | * Browser only 71 | * 72 | * @param _image HTML image element or Image Bitmap 73 | * @param _pixels Count of maximum pixels accepted for the calculation 74 | * @returns Data of the reduced image 75 | */ 76 | export const extractImageData = ( 77 | _image: HTMLImageElement | ImageBitmap, 78 | _pixels: number, 79 | ) => { 80 | const currentPixels = _image.width * _image.height; 81 | const width = 82 | currentPixels < _pixels 83 | ? _image.width 84 | : Math.round(_image.width * Math.sqrt(_pixels / currentPixels)); 85 | const height = 86 | currentPixels < _pixels 87 | ? _image.height 88 | : Math.round(_image.height * Math.sqrt(_pixels / currentPixels)); 89 | 90 | const canvas = ((width: number, height: number) => { 91 | if (checkIsWorker()) { 92 | return new OffscreenCanvas(width, height); 93 | } 94 | const canvas = document.createElement("canvas"); 95 | canvas.width = width; 96 | canvas.height = height; 97 | return canvas; 98 | })(width, height); 99 | 100 | const context = canvas.getContext("2d") as 101 | | CanvasRenderingContext2D 102 | | OffscreenCanvasRenderingContext2D; 103 | context.drawImage( 104 | _image, 105 | 0, 106 | 0, 107 | _image.width, 108 | _image.height, 109 | 0, 110 | 0, 111 | width, 112 | height, 113 | ); 114 | 115 | return context.getImageData(0, 0, width, height); 116 | }; 117 | -------------------------------------------------------------------------------- /src/sort/AverageGroup.ts: -------------------------------------------------------------------------------- 1 | import Color from "../color/Color"; 2 | 3 | const distance = (a: number, b: number) => Math.abs(a - b); 4 | const hueDistance = (a: number, b: number) => 5 | Math.min(distance(a, b), distance((a + 0.5) % 1, (b + 0.5) % 1)); 6 | 7 | export class AverageGroup { 8 | colors: Color[] = []; 9 | private _average: Color | null = null; 10 | 11 | addColor(color: Color) { 12 | this.colors.push(color); 13 | this._average = null; 14 | } 15 | 16 | isSamePalette( 17 | color: Color, 18 | hue: number, 19 | saturation: number, 20 | lightness: number 21 | ) { 22 | for (const currentColor of this.colors) { 23 | const isSame = 24 | hueDistance(currentColor._hue, color._hue) < hue && 25 | distance(currentColor._saturation, color._saturation) < saturation && 26 | distance(currentColor._lightness, color._lightness) < lightness; 27 | 28 | if (!isSame) { 29 | return false; 30 | } 31 | } 32 | return true; 33 | } 34 | 35 | get average() { 36 | if (!this._average) { 37 | const { r, g, b } = this.colors.reduce( 38 | (total, color) => { 39 | total.r += color._red; 40 | total.g += color._green; 41 | total.b += color._blue; 42 | return total; 43 | }, 44 | { r: 0, g: 0, b: 0 } 45 | ); 46 | 47 | const total = this.colors.reduce( 48 | (_count, color) => _count + color._count, 49 | 0 50 | ); 51 | this._average = new Color( 52 | Math.round(r / this.colors.length), 53 | Math.round(g / this.colors.length), 54 | Math.round(b / this.colors.length) 55 | ); 56 | this._average._count = total; 57 | } 58 | return this._average; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/sort/AverageManager.ts: -------------------------------------------------------------------------------- 1 | import Color from "../color/Color"; 2 | import { AverageGroup } from "./AverageGroup"; 3 | 4 | export class AverageManager { 5 | _hue: number; 6 | _saturation: number; 7 | _lightness: number; 8 | 9 | private _groups: AverageGroup[] = []; 10 | 11 | constructor(hue: number, saturation: number, lightness: number) { 12 | this._hue = hue; 13 | this._saturation = saturation; 14 | this._lightness = lightness; 15 | } 16 | 17 | addColor(color: Color) { 18 | const samePalette = this._groups.find((averageGroup) => 19 | averageGroup.isSamePalette( 20 | color, 21 | this._hue, 22 | this._saturation, 23 | this._lightness 24 | ) 25 | ); 26 | if (samePalette) { 27 | samePalette.addColor(color); 28 | } else { 29 | const averageGroup = new AverageGroup(); 30 | averageGroup.addColor(color); 31 | this._groups.push(averageGroup); 32 | } 33 | } 34 | 35 | getGroups() { 36 | return this._groups.map((averageGroup) => averageGroup.average); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/sort/sortColors.ts: -------------------------------------------------------------------------------- 1 | import Color from "../color/Color"; 2 | import { AverageManager } from "./AverageManager"; 3 | 4 | export default ( 5 | list: Color[], 6 | _pixels: number, 7 | _hueDistance: number, 8 | _saturationDistance: number, 9 | _lightnessDistance: number 10 | ) => { 11 | const averageManager = new AverageManager( 12 | _hueDistance, 13 | _saturationDistance, 14 | _lightnessDistance 15 | ); 16 | list.forEach((color) => averageManager.addColor(color)); 17 | 18 | const sorted = averageManager.getGroups(); 19 | 20 | sorted.sort((a, b) => { 21 | const bPower = (b._intensity + 0.1) * (0.9 - b._count / _pixels); 22 | const aPower = (a._intensity + 0.1) * (0.9 - a._count / _pixels); 23 | return bPower - aPower; 24 | }); 25 | return sorted; 26 | }; 27 | -------------------------------------------------------------------------------- /src/types/Color.ts: -------------------------------------------------------------------------------- 1 | export interface RGB { 2 | red: number; 3 | green: number; 4 | blue: number; 5 | } 6 | 7 | export interface FinalColor { 8 | hex: string; 9 | red: number; 10 | green: number; 11 | blue: number; 12 | area: number; 13 | hue: number; 14 | saturation: number; 15 | lightness: number; 16 | intensity: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/types/NodeImageData.ts: -------------------------------------------------------------------------------- 1 | export type NodeImageData = 2 | | { 3 | width: number; 4 | height: number; 5 | data: Uint8ClampedArray | number[]; 6 | } 7 | | ImageData; 8 | -------------------------------------------------------------------------------- /src/types/Options.ts: -------------------------------------------------------------------------------- 1 | export interface ImageDataAlt { 2 | data: Uint8ClampedArray | number[]; 3 | width?: number; 4 | height?: number; 5 | } 6 | 7 | export interface SorterOptions { 8 | saturationDistance?: number; 9 | lightnessDistance?: number; 10 | hueDistance?: number; 11 | } 12 | 13 | export interface ExtractorOptions { 14 | pixels?: number; 15 | distance?: number; 16 | colorValidator?: ( 17 | red: number, 18 | green: number, 19 | blue: number, 20 | alpha: number, 21 | ) => boolean; 22 | } 23 | 24 | export type BrowserOptions = ExtractorOptions & { 25 | crossOrigin?: "anonymous" | "use-credentials" | ""; 26 | requestMode?: RequestMode; 27 | } & SorterOptions; 28 | 29 | export type NodeOptions = ExtractorOptions & SorterOptions; 30 | 31 | export type OptionsCleaned = [ 32 | number, 33 | number, 34 | (red: number, green: number, blue: number, alpha: number) => boolean, 35 | number, 36 | number, 37 | number, 38 | "" | "anonymous" | "use-credentials" | null, 39 | RequestMode, 40 | ]; 41 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | import Color from "./color/Color"; 2 | import sortColors from "./sort/sortColors"; 3 | import { createFinalColor } from "./color/FinalColor"; 4 | import type { ImageDataAlt, OptionsCleaned } from "./types/Options"; 5 | import extractor from "./extract/extractor"; 6 | import type { FinalColor } from "./types/Color"; 7 | 8 | /** 9 | * Sort colors and generate standard list of colors. 10 | * 11 | * @param _colors List of colors 12 | * @param _pixels Count of pixels in the image 13 | * @param _hueDistance Maximal HUE distance between pixel before pixel merging 14 | * @param _saturationDistance Maximal saturation distance between pixel before pixel merging 15 | * @param _lightnessDistance Maximal lightness distance between pixel before pixel merging 16 | * @returns Sorted colors list 17 | */ 18 | const _sortFinalColors = ( 19 | _colors: Color[], 20 | _pixels: number, 21 | _hueDistance: number, 22 | _saturationDistance: number, 23 | _lightnessDistance: number 24 | ) => { 25 | const list = sortColors( 26 | _colors, 27 | _pixels, 28 | _hueDistance, 29 | _saturationDistance, 30 | _lightnessDistance 31 | ); 32 | return list.map((color) => createFinalColor(color, _pixels)); 33 | }; 34 | 35 | /** 36 | * Extract ImageData from image. 37 | * Reduce image to a pixel count. 38 | * Browser only 39 | * 40 | * @param _image HTML image element or Image Bitmap 41 | * @param _pixels Count of maximum pixels accepted for the calculation 42 | * @returns Data of the reduced image 43 | */ 44 | const _getImageData = ( 45 | _image: HTMLImageElement | ImageBitmap, 46 | _pixels: number 47 | ) => { 48 | const currentPixels = _image.width * _image.height; 49 | const width = 50 | currentPixels < _pixels 51 | ? _image.width 52 | : Math.round(_image.width * Math.sqrt(_pixels / currentPixels)); 53 | const height = 54 | currentPixels < _pixels 55 | ? _image.height 56 | : Math.round(_image.height * Math.sqrt(_pixels / currentPixels)); 57 | 58 | const canvas = new OffscreenCanvas(width, height); 59 | const context = canvas.getContext("2d") as OffscreenCanvasRenderingContext2D; 60 | context.drawImage( 61 | _image, 62 | 0, 63 | 0, 64 | _image.width, 65 | _image.height, 66 | 0, 67 | 0, 68 | width, 69 | height 70 | ); 71 | 72 | return context.getImageData(0, 0, width, height); 73 | }; 74 | 75 | /** 76 | * Extract colors from an ImageData object. 77 | * 78 | * @param imageData Data of the image 79 | * @param cleanOptions Process configuration options cleaned 80 | * 81 | * @returns List of extracted colors 82 | */ 83 | const _extractColorsFromImageData = ( 84 | imageData: ImageData | ImageDataAlt, 85 | cleanOptions: OptionsCleaned 86 | ) => { 87 | const [ 88 | _pixels, 89 | _distance, 90 | _colorValidator, 91 | _hueDistance, 92 | _saturationDistance, 93 | _lightnessDistance, 94 | ] = cleanOptions; 95 | const { colors, count } = extractor( 96 | imageData, 97 | _pixels, 98 | _distance, 99 | _colorValidator 100 | ); 101 | return _sortFinalColors( 102 | colors, 103 | count, 104 | _hueDistance, 105 | _saturationDistance, 106 | _lightnessDistance 107 | ); 108 | }; 109 | 110 | /** 111 | * Extract colors from an ImageBitmap object. 112 | * 113 | * @param image image bitmap 114 | * @param cleanOptions Process configuration options cleaned 115 | * 116 | * @returns List of extracted colors 117 | */ 118 | const _extractColorsFromImageBitmap = async ( 119 | image: ImageBitmap, 120 | cleanOptions: OptionsCleaned 121 | ): Promise => { 122 | const [ 123 | _pixels, 124 | _distance, 125 | _colorValidator, 126 | _hueDistance, 127 | _saturationDistance, 128 | _lightnessDistance, 129 | ] = cleanOptions; 130 | 131 | const imageData = _getImageData(image, _pixels); 132 | const { colors, count } = extractor( 133 | imageData, 134 | _pixels, 135 | _distance, 136 | _colorValidator 137 | ); 138 | 139 | return _sortFinalColors( 140 | colors, 141 | count, 142 | _hueDistance, 143 | _saturationDistance, 144 | _lightnessDistance 145 | ); 146 | }; 147 | 148 | /** 149 | * Extract colors from a path. 150 | * The image will be downloaded. 151 | * 152 | * @param src Image source 153 | * @param cleanOptions Process configuration options cleaned 154 | * 155 | * @returns List of extracted colors 156 | */ 157 | const _extractColorsFromSrc = async ( 158 | src: string, 159 | cleanOptions: OptionsCleaned 160 | ): Promise => { 161 | const response = await fetch(src, { mode: cleanOptions[7] }); 162 | const blob = await response.blob(); 163 | const bitmap = await createImageBitmap(blob); 164 | const colors = await _extractColorsFromImageBitmap(bitmap, cleanOptions); 165 | bitmap.close(); 166 | return colors; 167 | }; 168 | 169 | /** 170 | * Extract colors from a picture. 171 | * 172 | * @param picture image source or image data (node.js context only support image data) 173 | * @param cleanOptions Process configuration options cleaned 174 | * @param callback Function with list of extracted colors in first parameter 175 | */ 176 | const extractColors = async ( 177 | picture: string | ImageData | ImageDataAlt, 178 | cleanOptions: OptionsCleaned, 179 | callback: (list: FinalColor[]) => void 180 | ) => { 181 | if ( 182 | picture instanceof ImageData || 183 | (picture instanceof Object && (picture as ImageDataAlt).data) 184 | ) { 185 | return callback( 186 | _extractColorsFromImageData( 187 | picture as ImageData | ImageDataAlt, 188 | cleanOptions 189 | ) 190 | ); 191 | } 192 | 193 | if (typeof picture === "string") { 194 | return callback(await _extractColorsFromSrc(picture, cleanOptions)); 195 | } 196 | 197 | throw new Error(`Can not analyse picture`); 198 | }; 199 | 200 | // Listend and send data to Worker Wrapper 201 | onmessage = (message) => { 202 | const [ 203 | picture, 204 | [_pixels, _distance, _colorValidatorStr, ..._cleanInputsRest], 205 | ] = message.data as Parameters; 206 | extractColors( 207 | picture, 208 | [ 209 | _pixels, 210 | _distance, 211 | Function(`return ${_colorValidatorStr}`)(), 212 | ..._cleanInputsRest, 213 | ], 214 | postMessage 215 | ); 216 | }; 217 | -------------------------------------------------------------------------------- /src/workerWrapper.ts: -------------------------------------------------------------------------------- 1 | import WorkerWrapper from "./worker?worker&inline"; 2 | import cleanInputs, { testInputs } from "./extract/cleanInputs"; 3 | import type { BrowserOptions, ImageDataAlt } from "./types/Options"; 4 | import type { FinalColor } from "./types/Color"; 5 | 6 | /** 7 | * Extract colors from a picture with Web Worker support. 8 | * 9 | * @param picture image source or image data 10 | * @param options Process configuration 11 | * @param options.pixels Total pixel number of the resized picture for calculation 12 | * @param options.distance From 0 to 1 is the color distance to not have near colors (1 distance is between white and black) 13 | * @param options.colorValidator Test function to enable only some colors 14 | * @param options.saturationDistance Minimum saturation value between two colors otherwise the colors will be merged (from 0 to 1) 15 | * @param options.lightnessDistance inimum lightness value between two colors otherwise the colors will be merged (from 0 to 1) 16 | * @param options.hueDistance inimum hue value between two colors otherwise the colors will be merged (from 0 to 1) 17 | * @param options.requestMode support for CORS (only for Web Workers in browser) 18 | * 19 | * @returns List of extracted colors 20 | */ 21 | export const extractColors = ( 22 | picture: string | ImageData | ImageDataAlt, 23 | options?: BrowserOptions 24 | ) => { 25 | if (__DEV__) { 26 | testInputs(options); 27 | } 28 | 29 | if (picture instanceof HTMLImageElement) { 30 | if (__DEV__) { 31 | console.warn( 32 | "HTMLImageElement not enable on worker, please send 'src' or image data instead HTMLImageElement" 33 | ); 34 | } 35 | 36 | // HTMLImageElement not enable on Worker, switch to src fallback 37 | picture = picture.src; 38 | } 39 | 40 | const [_pixels, _distance, _colorValidator, ..._cleanInputsRest] = 41 | cleanInputs(options); 42 | 43 | // Wrap worker inside Promise 44 | return new Promise((resolve, reject) => { 45 | try { 46 | const worker: Worker = new WorkerWrapper(); 47 | worker.postMessage([ 48 | picture, 49 | [_pixels, _distance, _colorValidator.toString(), ..._cleanInputsRest], 50 | ]); 51 | worker.addEventListener("message", (message) => { 52 | resolve(message.data); 53 | worker.terminate(); 54 | }); 55 | worker.addEventListener("error", (error) => { 56 | reject(error); 57 | worker.terminate(); 58 | }); 59 | } catch (error) { 60 | reject(error); 61 | } 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /tests/averageGroup.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import Color from "../src/color/Color"; 3 | import { AverageGroup } from "../src/sort/AverageGroup"; 4 | 5 | describe("Average group", () => { 6 | it("Near average", () => { 7 | const av = new AverageGroup(); 8 | av.addColor(new Color(0xff, 0xff, 0xff)); 9 | av.addColor(new Color(0xfd, 0xfd, 0xfd)); 10 | expect(av.average._red).toBe(0xfe); 11 | expect(av.average._green).toBe(0xfe); 12 | expect(av.average._blue).toBe(0xfe); 13 | }); 14 | 15 | it("Extreme average", () => { 16 | const av = new AverageGroup(); 17 | av.addColor(new Color(0xff, 0xff, 0xff)); 18 | av.addColor(new Color(0x00, 0x00, 0x00)); 19 | expect(av.average._red).toBe(0x80); 20 | expect(av.average._green).toBe(0x80); 21 | expect(av.average._blue).toBe(0x80); 22 | }); 23 | 24 | it("Extreme 3 average", () => { 25 | const av = new AverageGroup(); 26 | av.addColor(new Color(0xff, 0xff, 0xff)); 27 | av.addColor(new Color(0x80, 0x80, 0x80)); 28 | av.addColor(new Color(0x00, 0x00, 0x00)); 29 | expect(av.average._red).toBe(0x80); 30 | expect(av.average._green).toBe(0x80); 31 | expect(av.average._blue).toBe(0x80); 32 | }); 33 | 34 | it("Same palette", () => { 35 | const av = new AverageGroup(); 36 | av.addColor(new Color(0xff, 0xff, 0xff)); 37 | expect( 38 | av.isSamePalette(new Color(0xf0, 0xf0, 0xf0), 0.1, 0.1, 0.1) 39 | ).toBeTruthy(); 40 | }); 41 | 42 | it("Not same palette", () => { 43 | const av = new AverageGroup(); 44 | av.addColor(new Color(0x70, 0x70, 0x70)); 45 | expect( 46 | av.isSamePalette(new Color(0xf0, 0xf0, 0xf0), 0.1, 0.1, 0.1) 47 | ).toBeFalsy(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/averageManager.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import Color from "../src/color/Color"; 3 | import { 4 | AVERAGE_HUE_DEFAULT, 5 | AVERAGE_SATURATION_DEFAULT, 6 | AVERAGE_LIGHTNESS_DEFAULT, 7 | } from "../src/extract/cleanInputs"; 8 | import { AverageManager } from "../src/sort/AverageManager"; 9 | 10 | describe("Average group", () => { 11 | it("Differents groups", () => { 12 | const avm = new AverageManager( 13 | AVERAGE_HUE_DEFAULT, 14 | AVERAGE_SATURATION_DEFAULT, 15 | AVERAGE_LIGHTNESS_DEFAULT 16 | ); 17 | avm.addColor(new Color(0xff, 0xff, 0xff)); 18 | avm.addColor(new Color(0x00, 0x00, 0x00)); 19 | avm.addColor(new Color(0x77, 0x77, 0x77)); 20 | 21 | expect(avm.getGroups().length).toBe(3); 22 | }); 23 | 24 | it("Similar groups", () => { 25 | const avm = new AverageManager( 26 | AVERAGE_HUE_DEFAULT, 27 | AVERAGE_SATURATION_DEFAULT, 28 | AVERAGE_LIGHTNESS_DEFAULT 29 | ); 30 | avm.addColor(new Color(0xff, 0xff, 0xff)); 31 | avm.addColor(new Color(0xee, 0xee, 0xee)); 32 | avm.addColor(new Color(0x77, 0x77, 0x77)); 33 | 34 | expect(avm.getGroups().length).toBe(2); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/browser.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { 3 | extractColors, 4 | extractColorsFromImageData, 5 | } from "../src/extractColors"; 6 | 7 | // Mock Image 8 | class Image { 9 | complete = true; 10 | width = 2; 11 | height = 2; 12 | } 13 | 14 | class ImageLoadable extends Image { 15 | private _cb = () => 1; 16 | 17 | constructor() { 18 | super(); 19 | this.complete = false; 20 | setTimeout(() => { 21 | this.complete = true; 22 | this._cb(); 23 | }, 10); 24 | } 25 | 26 | addEventListener(_, cb) { 27 | this._cb = cb; 28 | } 29 | 30 | removeEventListener() { 31 | this._cb = () => 1; 32 | } 33 | } 34 | 35 | vi.stubGlobal("Image", Image); 36 | vi.stubGlobal("HTMLImageElement", Image); 37 | 38 | // Mock ImageData 39 | class ImageData { 40 | colorSpace = "srgb"; 41 | data = new Uint8ClampedArray([ 42 | 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 43 | 0xff, 0x00, 0x00, 0xff, 44 | ]); 45 | width = 2; 46 | height = 2; 47 | } 48 | 49 | vi.stubGlobal("ImageData", ImageData); 50 | 51 | // Mock document 52 | const document = { 53 | createElement: () => ({ 54 | width: 2, 55 | height: 2, 56 | getContext: () => ({ 57 | drawImage: () => 0, 58 | getImageData: () => new ImageData(), 59 | }), 60 | }), 61 | }; 62 | 63 | vi.stubGlobal("document", document); 64 | 65 | // Mock window 66 | const window = { 67 | document, 68 | }; 69 | 70 | vi.stubGlobal("window", window); 71 | 72 | // Disable Node.js context 73 | vi.stubGlobal("process.versions.node", undefined); 74 | 75 | describe("Browser", () => { 76 | it("Extract from imageData", () => { 77 | const imageData = new ImageData(); 78 | return expect(extractColorsFromImageData(imageData).length).toBeGreaterThan( 79 | 0, 80 | ); 81 | }); 82 | 83 | it("Extract from imageData 2", () => 84 | new Promise((done) => { 85 | const imageData = new ImageData(); 86 | return extractColors(imageData).then((data) => { 87 | expect(data.length).toBeGreaterThan(0); 88 | done(undefined); 89 | }); 90 | })); 91 | 92 | it("Extract from image", () => 93 | new Promise((done) => { 94 | const image = new Image() as HTMLImageElement; 95 | extractColors(image).then((data) => { 96 | expect(data.length).toBeGreaterThan(0); 97 | done(undefined); 98 | }); 99 | })); 100 | 101 | it("Extract from src", () => 102 | new Promise((done) => { 103 | extractColors("fakesrc.jpg").then((data) => { 104 | expect(data.length).toBeGreaterThan(0); 105 | done(undefined); 106 | }); 107 | })); 108 | 109 | it("Extract and reduce image", () => 110 | new Promise((done) => { 111 | const options = { 112 | pixels: 1, 113 | }; 114 | extractColors(new Image() as HTMLImageElement, options).then((data) => { 115 | expect(data.length).toBeGreaterThan(0); 116 | done(undefined); 117 | }); 118 | })); 119 | 120 | it("Extract from loadable image", () => 121 | new Promise((done) => { 122 | const options = { 123 | pixels: 1, 124 | }; 125 | extractColors( 126 | new ImageLoadable() as unknown as HTMLImageElement, 127 | options, 128 | ).then((data) => { 129 | expect(data.length).toBeGreaterThan(0); 130 | done(undefined); 131 | }); 132 | })); 133 | 134 | it("Bad arg", () => 135 | new Promise((done) => { 136 | return new Promise((resolve, reject) => { 137 | try { 138 | const out = extractColors(123 as unknown as string); 139 | resolve(out); 140 | } catch (error) { 141 | reject(error); 142 | } 143 | }).catch((error) => { 144 | expect(error.message).toBe("Can not analyse picture"); 145 | done(undefined); 146 | }); 147 | })); 148 | }); 149 | -------------------------------------------------------------------------------- /tests/cleanInput.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, afterAll } from "vitest"; 2 | 3 | import cleanInputs, { testInputs } from "../src/extract/cleanInputs"; 4 | 5 | describe("cleanInputs", () => { 6 | const consoleMock = vi.spyOn(console, "warn").mockImplementation(() => 1); 7 | 8 | afterAll(() => { 9 | consoleMock.mockReset(); 10 | }); 11 | 12 | it("test errors", () => { 13 | expect(() => testInputs({ pixels: 0.1 })).toThrowError(/.*/); 14 | expect(() => testInputs({ pixels: "a" as unknown as number })).toThrowError( 15 | /.*/ 16 | ); 17 | expect(() => 18 | testInputs({ hueDistance: "a" as unknown as number }) 19 | ).toThrowError(/.*/); 20 | expect(() => 21 | testInputs({ saturationDistance: "a" as unknown as number }) 22 | ).toThrowError(/.*/); 23 | expect(() => 24 | testInputs({ distance: "a" as unknown as number }) 25 | ).toThrowError(/.*/); 26 | expect(() => 27 | testInputs({ lightnessDistance: "a" as unknown as number }) 28 | ).toThrowError(/.*/); 29 | expect(() => 30 | testInputs({ 31 | colorValidator: "a" as unknown as ( 32 | red: number, 33 | green: number, 34 | blue: number, 35 | alpha: number 36 | ) => boolean, 37 | }) 38 | ).toThrowError(/.*/); 39 | }); 40 | 41 | it("test warnings", () => { 42 | testInputs({ pixels: -1 }); 43 | testInputs({ pixels: Number.MAX_SAFE_INTEGER + 1 }); 44 | testInputs({ hueDistance: -1 }); 45 | testInputs({ saturationDistance: -1 }); 46 | testInputs({ distance: -1 }); 47 | testInputs({ lightnessDistance: -1 }); 48 | testInputs({ hueDistance: 2 }); 49 | testInputs({ saturationDistance: 2 }); 50 | testInputs({ distance: 2 }); 51 | testInputs({ lightnessDistance: 2 }); 52 | 53 | expect(consoleMock).toHaveBeenCalledTimes(10); 54 | }); 55 | 56 | it("test min", () => { 57 | expect(cleanInputs({ pixels: -1 })[0]).toBe(1); 58 | expect(cleanInputs({ hueDistance: -1 })[3]).toBe(0); 59 | expect(cleanInputs({ saturationDistance: -1 })[4]).toBe(0); 60 | expect(cleanInputs({ distance: -1 })[1]).toBe(0); 61 | expect(cleanInputs({ lightnessDistance: -1 })[5]).toBe(0); 62 | }); 63 | 64 | it("test max", () => { 65 | expect(cleanInputs({ hueDistance: 2 })[3]).toBe(1); 66 | expect(cleanInputs({ saturationDistance: 2 })[4]).toBe(1); 67 | expect(cleanInputs({ distance: 2 })[1]).toBe(1); 68 | expect(cleanInputs({ lightnessDistance: 2 })[5]).toBe(1); 69 | }); 70 | 71 | it("default", () => { 72 | expect(cleanInputs({ pixels: null as unknown as number })[0]).toBe(1); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /tests/color.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import Color from "../src/color/Color"; 3 | 4 | describe("Color", () => { 5 | it("Color hexa from chanels", () => { 6 | const red = 0xf7; 7 | const green = 0x78; 8 | const blue = 0x01; 9 | const hex = 0xf77801; 10 | const color = new Color(red, green, blue); 11 | expect(color._red).toBe(red); 12 | expect(color._green).toBe(green); 13 | expect(color._blue).toBe(blue); 14 | expect(color._hex).toBe(hex); 15 | expect(color._count).toBe(1); 16 | }); 17 | 18 | it("Color distance far", () => { 19 | const color1 = new Color(0xff, 0xff, 0xff); 20 | const color2 = new Color(0x00, 0x00, 0x00); 21 | expect(Color.distance(color1, color2)).toBe(1); 22 | expect(Color.distance(color2, color1)).toBe(1); 23 | }); 24 | 25 | it("Color distance near", () => { 26 | const color1 = new Color(0xff, 0xff, 0xff); 27 | const color2 = new Color(0xff, 0xff, 0xff); 28 | expect(Color.distance(color1, color2)).toBe(0); 29 | expect(Color.distance(color2, color1)).toBe(0); 30 | }); 31 | 32 | it("Get HSL", () => { 33 | const color1 = new Color(0xff, 0xff, 0xff); 34 | const color2 = new Color(0x00, 0x00, 0x00); 35 | expect(color1._saturation).toBe(0); 36 | expect(color1._lightness).toBe(1); 37 | expect(color2._lightness).toBe(0); 38 | }); 39 | 40 | it("Test blue color", () => { 41 | const color1 = new Color(0x00, 0x00, 0xff); 42 | expect(color1._hue).toBe(240 / 360); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/extractor.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import cleanInputs, { testInputs } from "../src/extract/cleanInputs"; 3 | import extractor from "../src/extract/extractor"; 4 | import { ExtractorOptions } from "../src/types/Options"; 5 | 6 | const imageData4 = { 7 | width: 2, 8 | height: 2, 9 | data: [ 10 | 0xff, 0xff, 0x00, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0xff, 11 | 0xff, 0xff, 0xff, 0xff, 12 | ], 13 | }; 14 | 15 | const throwTest = async ( 16 | testName: string, 17 | options: ExtractorOptions, 18 | errorMessage: string 19 | ) => { 20 | it( 21 | testName, 22 | () => 23 | new Promise((done) => { 24 | return new Promise((resolve, reject) => { 25 | try { 26 | testInputs(options); 27 | const [pixels, distance, colorValidator] = cleanInputs(options); 28 | const { colors } = extractor( 29 | imageData4, 30 | pixels, 31 | distance, 32 | colorValidator 33 | ); 34 | resolve(colors); 35 | } catch (error) { 36 | reject(error); 37 | } 38 | }).catch((error) => { 39 | expect(error.message).toBe(errorMessage); 40 | done(undefined); 41 | }); 42 | }) 43 | ); 44 | }; 45 | 46 | const warns: string[] = []; 47 | 48 | vi.spyOn(global.console, "warn").mockImplementation((message) => { 49 | warns.push(message); 50 | }); 51 | 52 | const testWarn = async ( 53 | testName: string, 54 | options: ExtractorOptions, 55 | errorMessage: string 56 | ) => { 57 | it( 58 | testName, 59 | () => 60 | new Promise((done) => { 61 | return new Promise((resolve) => { 62 | testInputs(options); 63 | const [pixels, distance, colorValidator] = cleanInputs(options); 64 | const { colors } = extractor( 65 | imageData4, 66 | pixels, 67 | distance, 68 | colorValidator 69 | ); 70 | resolve(colors); 71 | }).then(() => { 72 | expect(warns.pop()).toBe(errorMessage); 73 | done(undefined); 74 | }); 75 | }) 76 | ); 77 | }; 78 | 79 | describe("Color", () => { 80 | it("No width height ", () => { 81 | const imageData = { 82 | width: 0, 83 | height: 0, 84 | data: [ 85 | 0x00, 0xff, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 86 | 0x00, 0x00, 0x00, 0xff, 87 | ], 88 | }; 89 | 90 | const [pixels, distance, colorValidator] = cleanInputs({}); 91 | expect( 92 | extractor(imageData, pixels, distance, colorValidator).colors.length 93 | ).toBe(4); 94 | }); 95 | 96 | it("Reducer by 4", () => { 97 | const imageData = { 98 | width: 2, 99 | height: 2, 100 | data: [ 101 | 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 102 | 0xff, 0x00, 0x00, 0xff, 103 | ], 104 | }; 105 | 106 | const [pixels, distance, colorValidator] = cleanInputs({ pixels: 1 }); 107 | expect( 108 | extractor(imageData, pixels, distance, colorValidator).colors.length 109 | ).toBe(1); 110 | }); 111 | 112 | it("Alpha reducer by 3", () => { 113 | const imageData = { 114 | width: 2, 115 | height: 2, 116 | data: [ 117 | 0x00, 0xff, 0x00, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 118 | 0x00, 0x00, 0x00, 0x00, 119 | ], 120 | }; 121 | 122 | const [pixels, distance, colorValidator] = cleanInputs({ 123 | pixels: 4, 124 | colorValidator: (r, g, b, a) => a > 0, 125 | }); 126 | 127 | expect( 128 | extractor(imageData, pixels, distance, colorValidator).colors.length 129 | ).toBe(3); 130 | }); 131 | 132 | it("No reducer", () => { 133 | const [pixels, distance, colorValidator] = cleanInputs({ pixels: 4 }); 134 | expect( 135 | extractor(imageData4, pixels, distance, colorValidator).colors.length 136 | ).toBe(4); 137 | }); 138 | 139 | it("Merge colors", () => { 140 | const imageData = { 141 | width: 2, 142 | height: 4, 143 | data: [ 144 | 0xff, 0xff, 0x00, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0xff, 145 | 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, 0x00, 0x00, 0xff, 0xff, 146 | 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 147 | ], 148 | }; 149 | 150 | const [pixels, distance, colorValidator] = cleanInputs({ pixels: 8 }); 151 | expect( 152 | extractor(imageData, pixels, distance, colorValidator).colors.length 153 | ).toBe(4); 154 | }); 155 | 156 | type Cb = ( 157 | red: number, 158 | green: number, 159 | blue: number, 160 | alpha: number 161 | ) => boolean; 162 | 163 | testWarn( 164 | "Little pixels", 165 | { pixels: -1 }, 166 | "pixels can not be less than 1 (it's -1)" 167 | ); 168 | throwTest( 169 | "Float pixels", 170 | { pixels: 1.2 }, 171 | "pixels is not a valid number (1.2)" 172 | ); 173 | throwTest( 174 | "Large pixels", 175 | { pixels: Number.POSITIVE_INFINITY }, 176 | "pixels is not a valid number (Infinity)" 177 | ); 178 | testWarn( 179 | "Little distance", 180 | { distance: -0.1 }, 181 | "distance can not be less than 0 (it's -0.1)" 182 | ); 183 | testWarn( 184 | "Bad distance", 185 | { distance: 1.0001 }, 186 | "distance can not be more than 1 (it's 1.0001)" 187 | ); 188 | testWarn( 189 | "Large distance", 190 | { distance: 2 }, 191 | "distance can not be more than 1 (it's 2)" 192 | ); 193 | throwTest( 194 | "Number colorValidator", 195 | { colorValidator: 1 as unknown as Cb }, 196 | "colorValidator is not a function (1)" 197 | ); 198 | throwTest( 199 | "String colorValidator", 200 | { colorValidator: "a" as unknown as Cb }, 201 | "colorValidator is not a function (a)" 202 | ); 203 | }); 204 | -------------------------------------------------------------------------------- /tests/extractorColorsCjs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | */ 4 | import { describe, it, expect } from "vitest"; 5 | import type { extractColors as extractColorsSrc } from "../src/extractColors"; 6 | import { extractColors as extractColorsCjs } from "../lib/extract-colors.cjs"; 7 | 8 | // For typing cjs source 9 | const extractColors = extractColorsCjs as typeof extractColorsSrc; 10 | 11 | let seed = 654654331 % 2147483647; 12 | const rand = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646; 13 | 14 | const createImageData = (width: number, height: number) => { 15 | return { 16 | width, 17 | height, 18 | data: new Array(width * height) 19 | .fill(true) 20 | .map(() => [ 21 | Math.floor(rand() * 0xff), 22 | Math.floor(rand() * 0xff), 23 | Math.floor(rand() * 0xff), 24 | 0xff, 25 | ]) 26 | .flat(), 27 | }; 28 | }; 29 | 30 | const createCustomImageData = (colors: number[]) => { 31 | return { 32 | width: colors.length, 33 | height: 1, 34 | data: colors 35 | .map((color) => [ 36 | (color >> 16) & 0xff, 37 | (color >> 8) & 0xff, 38 | (color >> 0) & 0xff, 39 | 0xff, 40 | ]) 41 | .flat(), 42 | }; 43 | }; 44 | 45 | describe("CJS", () => { 46 | it("Not near options", () => 47 | new Promise((done) => { 48 | const imageData = createImageData(3, 3); 49 | return extractColors(imageData, { 50 | distance: 0, 51 | hueDistance: 0, 52 | lightnessDistance: 0, 53 | saturationDistance: 0, 54 | }).then((data) => { 55 | expect(data.length).toBe(3 * 3); 56 | done(undefined); 57 | }); 58 | })); 59 | 60 | it("Little pixels", () => 61 | new Promise((done) => { 62 | const imageData = createImageData(3, 3); 63 | return extractColors(imageData, { 64 | pixels: 1, 65 | distance: 0, 66 | hueDistance: 0, 67 | lightnessDistance: 0, 68 | saturationDistance: 0, 69 | }).then((data) => { 70 | expect(data.length).toBe(1); 71 | done(undefined); 72 | }); 73 | })); 74 | 75 | it("Big pixels", () => 76 | new Promise((done) => { 77 | const imageData = createImageData(3, 3); 78 | return extractColors(imageData, { 79 | pixels: 1000, 80 | distance: 0, 81 | hueDistance: 0, 82 | lightnessDistance: 0, 83 | saturationDistance: 0, 84 | }).then((data) => { 85 | expect(data.length).toBe(3 * 3); 86 | done(undefined); 87 | }); 88 | })); 89 | 90 | it("Small distance", () => 91 | new Promise((done) => { 92 | const imageData = createCustomImageData([0xffffff, 0xeeeeee]); 93 | return extractColors(imageData, { 94 | distance: 0, 95 | hueDistance: 0, 96 | lightnessDistance: 0, 97 | saturationDistance: 0, 98 | }).then((data) => { 99 | expect(data.length).toBe(2); 100 | done(undefined); 101 | }); 102 | })); 103 | 104 | it("Big distance", () => 105 | new Promise((done) => { 106 | const imageData = createCustomImageData([0xffffff, 0xeeeeee]); 107 | return extractColors(imageData, { 108 | distance: 0.25, 109 | hueDistance: 0, 110 | lightnessDistance: 0, 111 | saturationDistance: 0, 112 | }).then((data) => { 113 | expect(data.length).toBe(1); 114 | done(undefined); 115 | }); 116 | })); 117 | 118 | it("Big distance", () => 119 | new Promise((done) => { 120 | const imageData = createCustomImageData([0xffffff, 0xeeeeee]); 121 | return extractColors(imageData, { 122 | distance: 0.25, 123 | hueDistance: 0, 124 | lightnessDistance: 0, 125 | saturationDistance: 0, 126 | }).then((data) => { 127 | expect(data.length).toBe(1); 128 | done(undefined); 129 | }); 130 | })); 131 | 132 | it("Color validator", () => 133 | new Promise((done) => { 134 | const imageData = createCustomImageData([0xffffff, 0xff00bb]); 135 | return extractColors(imageData, { 136 | colorValidator: (r, g, b) => r === 0xff && g === 0x00 && b === 0xbb, 137 | hueDistance: 0, 138 | lightnessDistance: 0, 139 | saturationDistance: 0, 140 | }).then((data) => { 141 | expect(data.length).toBe(1); 142 | expect(data[0].hex).toBe("#ff00bb"); 143 | done(undefined); 144 | }); 145 | })); 146 | 147 | it("Small hue distance", () => 148 | new Promise((done) => { 149 | const imageData = createCustomImageData([0xff0000, 0xff1100]); 150 | return extractColors(imageData, { 151 | distance: 0, 152 | hueDistance: 0, 153 | lightnessDistance: 1, 154 | saturationDistance: 1, 155 | }).then((data) => { 156 | expect(data.length).toBe(2); 157 | done(undefined); 158 | }); 159 | })); 160 | 161 | it("Big hue distance", () => 162 | new Promise((done) => { 163 | const imageData = createCustomImageData([0xff0000, 0xff1100]); 164 | return extractColors(imageData, { 165 | distance: 0, 166 | hueDistance: 0.1, 167 | lightnessDistance: 1, 168 | saturationDistance: 1, 169 | }).then((data) => { 170 | expect(data.length).toBe(1); 171 | done(undefined); 172 | }); 173 | })); 174 | 175 | it("Small lightness distance", () => 176 | new Promise((done) => { 177 | const imageData = createCustomImageData([0xffffff, 0xeeeeee]); 178 | return extractColors(imageData, { 179 | distance: 0, 180 | hueDistance: 1, 181 | lightnessDistance: 0, 182 | saturationDistance: 1, 183 | }).then((data) => { 184 | expect(data.length).toBe(2); 185 | done(undefined); 186 | }); 187 | })); 188 | 189 | it("Big lightness distance", () => 190 | new Promise((done) => { 191 | const imageData = createCustomImageData([0xffffff, 0xeeeeee]); 192 | return extractColors(imageData, { 193 | distance: 0, 194 | hueDistance: 1, 195 | lightnessDistance: 0.1, 196 | saturationDistance: 1, 197 | }).then((data) => { 198 | expect(data.length).toBe(1); 199 | done(undefined); 200 | }); 201 | })); 202 | 203 | it("Small saturation distance", () => 204 | new Promise((done) => { 205 | const imageData = createCustomImageData([0x8b7476, 0x888888]); 206 | return extractColors(imageData, { 207 | distance: 0, 208 | hueDistance: 1, 209 | lightnessDistance: 1, 210 | saturationDistance: 0, 211 | }).then((data) => { 212 | expect(data.length).toBe(2); 213 | done(undefined); 214 | }); 215 | })); 216 | 217 | it("Big saturation distance", () => 218 | new Promise((done) => { 219 | const imageData = createCustomImageData([0x8b7476, 0x888888]); 220 | return extractColors(imageData, { 221 | distance: 0, 222 | hueDistance: 1, 223 | lightnessDistance: 1, 224 | saturationDistance: 0.1, 225 | }).then((data) => { 226 | expect(data.length).toBe(1); 227 | done(undefined); 228 | }); 229 | })); 230 | }); 231 | -------------------------------------------------------------------------------- /tests/finalColors.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import Color from "../src/color/Color"; 3 | import { createFinalColor } from "../src/color/FinalColor"; 4 | 5 | describe("Final color", () => { 6 | it("create", () => { 7 | const red = 0xf7; 8 | const green = 0x78; 9 | const blue = 0x01; 10 | const color = createFinalColor(new Color(red, green, blue), 10); 11 | expect(color.red).toBe(red); 12 | expect(color.green).toBe(green); 13 | expect(color.blue).toBe(blue); 14 | expect(color.hue).toBe(0.08062330623306234); 15 | expect(color.saturation).toBe(0.9919354838709677); 16 | expect(color.lightness).toBe(0.48627450980392156); 17 | expect(color.intensity).toBe(0.9647058823529412); 18 | expect(color.hex).toBe("#f77801"); 19 | expect(color.area).toBe(0.1); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/leafGroup.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | 3 | import LeafGroup from "../src/color/LeafGroup"; 4 | 5 | describe("LeafGroup", () => { 6 | it("Init", () => { 7 | const group = new LeafGroup(); 8 | expect(typeof group._children).toBe("object"); 9 | expect(group._count).toBe(0); 10 | }); 11 | 12 | it("Group colors", () => { 13 | const group = new LeafGroup(); 14 | group.addColor(0xff0077, 0xff, 0x00, 0x77); 15 | group.addColor(0x777777, 0x77, 0x77, 0x77); 16 | group.addColor(0x777777, 0x77, 0x77, 0x77); 17 | expect(group.getList().length).toBe(2); 18 | expect(group._count).toBe(3); 19 | // expect(group.getMaxWeightColor()._hex).toBe(0x777777) 20 | }); 21 | 22 | it("Get max _count color for 1 color", () => { 23 | const group = new LeafGroup(); 24 | group.addColor(0xffffff, 0xff, 0xff, 0xff); 25 | group.addColor(0xffffff, 0xff, 0xff, 0xff); 26 | group.addColor(0x000000, 0x00, 0x00, 0x00); 27 | // expect(group.getMaxCountColor()._count).toBe(2) 28 | // expect(group.getMaxCountColor()._hex).toBe(0xFFFFFF) 29 | }); 30 | 31 | it("Add color", () => { 32 | const group = new LeafGroup(); 33 | const color1 = group.addColor(0xff0077, 0xff, 0x00, 0x77); 34 | group.addColor(0xff0077, 0xff, 0x00, 0x77); 35 | const color3 = group.addColor(0xff0000, 0xff, 0x00, 0x00); 36 | expect(color1._count).toBe(2); 37 | expect(group.getList().length).toBe(2); 38 | expect(color3._count).toBe(1); 39 | // expect(group.getMaxWeight()).toBeCloseTo(2 / 3, 5) 40 | // expect(group.getMaxWeightColor()._hex).toBe(0xFF0077) 41 | }); 42 | 43 | it("Max weight", () => { 44 | const group = new LeafGroup(); 45 | group.addColor(0x0000ff, 0x00, 0x00, 0x77); 46 | group.addColor(0x0000ff, 0x00, 0x00, 0x77); 47 | group.addColor(0xff0000, 0xff, 0x00, 0x00); 48 | // expect(group.getMaxWeight()).toBe(2/3) 49 | // expect(group.getMaxWeight()).toBe(2/3) 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/namide-world.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Namide/extract-colors/673f66775e7abdf1d69d205d91a090ae5f7fec3e/tests/namide-world.jpg -------------------------------------------------------------------------------- /tests/node.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | */ 4 | import { describe, it, expect, vi } from "vitest"; 5 | import { 6 | extractColors, 7 | extractColorsFromImage, 8 | extractColorsFromSrc, 9 | } from "../src/extractColors"; 10 | 11 | const warns: string[] = []; 12 | 13 | vi.spyOn(global.console, "warn").mockImplementation((message) => { 14 | warns.push(message); 15 | }); 16 | 17 | describe("Node", () => { 18 | it("Check by color data", () => 19 | new Promise((done) => { 20 | const imageData = { 21 | width: 2, 22 | height: 2, 23 | data: [ 24 | 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 25 | 0xff, 0xff, 0x00, 0x00, 0xff, 26 | ], 27 | }; 28 | 29 | return extractColors(imageData as unknown as ImageData).then((data) => { 30 | expect(data.length).toBeGreaterThan(0); 31 | done(undefined); 32 | }); 33 | })); 34 | 35 | it("Check bad distance", () => 36 | new Promise((done) => { 37 | const imageData = { 38 | width: 2, 39 | height: 2, 40 | data: [ 41 | 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 42 | 0xff, 0xff, 0x00, 0x00, 0xff, 43 | ], 44 | }; 45 | 46 | const options = { 47 | distance: 1.1, 48 | }; 49 | 50 | return extractColors(imageData as unknown as ImageData, options).then( 51 | () => { 52 | expect(warns.pop()).toBe( 53 | "distance can not be more than 1 (it's 1.1)" 54 | ); 55 | done(undefined); 56 | } 57 | ); 58 | })); 59 | 60 | it("Use custom pixels", () => 61 | new Promise((done) => { 62 | const imageData = { 63 | width: 2, 64 | height: 2, 65 | data: [ 66 | 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff, 0xff, 0x00, 67 | 0xff, 0xff, 0x00, 0x00, 0xff, 68 | ], 69 | }; 70 | 71 | const options = { 72 | pixels: 1, 73 | }; 74 | 75 | return extractColors(imageData as unknown as ImageData, options).then( 76 | (data) => { 77 | expect(data.length).toBe(1); 78 | done(undefined); 79 | } 80 | ); 81 | })); 82 | 83 | it("Bad imageData", () => 84 | new Promise((done) => { 85 | return new Promise((resolve, reject) => { 86 | try { 87 | const out = extractColors({} as ImageData); 88 | resolve(out); 89 | } catch (error) { 90 | reject(error); 91 | } 92 | }).catch((error) => { 93 | expect(error.message).toBe("Send imageData to extractColors"); 94 | done(undefined); 95 | }); 96 | })); 97 | 98 | it("Can not open extractColorsFromImage", () => 99 | new Promise((done) => { 100 | return new Promise((resolve, reject) => { 101 | return extractColorsFromImage({} as HTMLImageElement) 102 | .then(resolve) 103 | .catch(reject); 104 | }).catch((error) => { 105 | expect(error.message).toBe( 106 | "Use extractColors instead extractColorsFromImage for Node.js" 107 | ); 108 | done(undefined); 109 | }); 110 | })); 111 | 112 | it("Can not open extractColorsFromSrc", () => 113 | new Promise((done) => { 114 | return new Promise((resolve, reject) => { 115 | return extractColorsFromSrc("").then(resolve).catch(reject); 116 | }).catch((error) => { 117 | expect(error.message).toBe( 118 | "Can not use extractColorsFromSrc for Node.js" 119 | ); 120 | done(undefined); 121 | }); 122 | })); 123 | }); 124 | -------------------------------------------------------------------------------- /tests/rootGroup.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import RootGroup from "../src/color/RootGroup"; 3 | 4 | describe("RootGroup", () => { 5 | it("Color group init", () => { 6 | const group = new RootGroup(); 7 | expect(typeof group._children).toBe("object"); 8 | expect(group._count).toBe(0); 9 | }); 10 | 11 | it("Add group", () => { 12 | const group = new RootGroup(); 13 | group.addColor(0xff, 0x00, 0x77); 14 | group.addColor(0x77, 0x77, 0x77); 15 | group.addColor(0x77, 0x77, 0x77); 16 | expect(group._count).toBe(3); 17 | expect(group.getList().length).toBe(2); 18 | expect(group.getColors(0)[0]._hex).toBe(0x777777); 19 | }); 20 | 21 | it("Get max _count color for 1 color", () => { 22 | const group = new RootGroup(); 23 | group.addColor(0xff, 0xff, 0xff); 24 | group.addColor(0xff, 0xff, 0xff); 25 | group.addColor(0x00, 0x00, 0x00); 26 | expect(group.getColors(0).length).toBe(2); 27 | expect(group._count).toBe(3); 28 | expect( 29 | group.getColors(0).reduce((total, color) => total + color._count, 0) 30 | ).toBe(3); 31 | expect(group.getColors(0).length).toBe(2); 32 | }); 33 | 34 | it("Add deep group", () => { 35 | const group = new RootGroup(); 36 | const group4 = group.getLeafGroup(0xffff); 37 | group4.addColor(0xffffff, 0xff, 0xff, 0xff); 38 | group4.addColor(0xffffff, 0xff, 0xff, 0xff); 39 | group4.addColor(0xf7f7f7, 0xf7, 0xf7, 0xf7); 40 | group4.addColor(0xf9f9f9, 0xf9, 0xf9, 0xf9); 41 | expect(group.getColors(0)[0]._hex).toBe(0xffffff); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/sortColors.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import Color from "../src/color/Color"; 3 | import { 4 | AVERAGE_HUE_DEFAULT, 5 | AVERAGE_SATURATION_DEFAULT, 6 | AVERAGE_LIGHTNESS_DEFAULT, 7 | } from "../src/extract/cleanInputs"; 8 | import sortColors from "../src/sort/sortColors"; 9 | 10 | describe("Sort color", () => { 11 | it("Sort by area", () => { 12 | const colors = sortColors( 13 | [ 14 | new Color(0xff, 0xff, 0xff), 15 | new Color(0xff, 0xff, 0xff), 16 | new Color(0x77, 0x77, 0x77), 17 | ], 18 | 10, 19 | AVERAGE_HUE_DEFAULT, 20 | AVERAGE_SATURATION_DEFAULT, 21 | AVERAGE_LIGHTNESS_DEFAULT 22 | ); 23 | 24 | expect(colors.length).toBe(2); 25 | expect(colors[0]._red).toBe(0x77); 26 | }); 27 | 28 | it("Sort by saturation", () => { 29 | const colors = sortColors( 30 | [new Color(0x73, 0x76, 0x72), new Color(0xff, 0x00, 0x77)], 31 | 10, 32 | AVERAGE_HUE_DEFAULT, 33 | AVERAGE_SATURATION_DEFAULT, 34 | AVERAGE_LIGHTNESS_DEFAULT 35 | ); 36 | 37 | expect(colors.length).toBe(2); 38 | expect(colors[0]._red).toBe(0xff); 39 | }); 40 | }); 41 | --------------------------------------------------------------------------------