├── .gitignore ├── .npmignore ├── test ├── test.jpg └── test.js ├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── LICENSE ├── package.json ├── README.md └── image-color.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | .env 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | .github 3 | .cache 4 | .env 5 | .editorconfig 6 | -------------------------------------------------------------------------------- /test/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/image-color/main/test/test.jpg -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node Unit Tests 2 | on: 3 | push: 4 | branches-ignore: 5 | - "gh-pages" 6 | jobs: 7 | build: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: ["ubuntu-latest", "macos-latest", "windows-latest"] 12 | node: ["18", "20", "22", "24"] 13 | name: Node.js ${{ matrix.node }} on ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node }} 20 | # cache: npm 21 | - run: npm install 22 | - run: npm test 23 | env: 24 | YARN_GPG: no 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release to npm 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | environment: GitHub Publish 9 | permissions: 10 | contents: read 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # 4.1.7 14 | - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # 4.0.3 15 | with: 16 | node-version: "22" 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: npm install -g npm@latest 19 | - run: npm ci 20 | - run: npm test 21 | - if: ${{ github.event.release.tag_name != '' && env.NPM_PUBLISH_TAG != '' }} 22 | run: npm publish --provenance --access=public --tag=${{ env.NPM_PUBLISH_TAG }} 23 | env: 24 | NPM_PUBLISH_TAG: ${{ contains(github.event.release.tag_name, '-beta.') && 'beta' || 'latest' }} 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Zach Leatherman @zachleat 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. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@11ty/image-color", 3 | "version": "1.0.8", 4 | "description": "Small utility to efficiently fetch the colors from an image.", 5 | "type": "module", 6 | "main": "image-color.js", 7 | "engines": { 8 | "node": ">=18" 9 | }, 10 | "scripts": { 11 | "test": "npx ava" 12 | }, 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "funding": { 17 | "type": "opencollective", 18 | "url": "https://opencollective.com/11ty" 19 | }, 20 | "author": "Zach Leatherman (https://zachleat.com/)", 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "git://github.com/11ty/image-color.git" 25 | }, 26 | "bugs": "https://github.com/11ty/image-color/issues", 27 | "dependencies": { 28 | "@11ty/eleventy-fetch": "^5.1.1", 29 | "@11ty/eleventy-img": "^6.0.4", 30 | "colorjs.io": "^0.5.2", 31 | "debug": "^4.4.3", 32 | "extract-colors": "^4.2.1", 33 | "memoize": "^10.2.0", 34 | "ndarray": "^1.0.19", 35 | "p-queue": "^8.1.1", 36 | "pngjs": "^7.0.0" 37 | }, 38 | "devDependencies": { 39 | "ava": "^6.4.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@11ty/image-color` 2 | 3 | A small utility to efficiently extract color information from images (with a memoization and a disk-cache). Requires Node 18 or newer. 4 | 5 | ## Installation 6 | 7 | Install from [npm: `@11ty/image-color`](https://www.npmjs.com/package/@11ty/image-color): 8 | 9 | ```sh 10 | npm install @11ty/image-color 11 | ``` 12 | 13 | Based on the great work of [`get-pixels`](https://www.npmjs.com/package/get-pixels/) (via the [`@zachleat/get-pixels` fork](https://github.com/zachleat/get-pixels)) and [`extract-colors`](https://www.npmjs.com/package/extract-colors). 14 | 15 | ## Usage 16 | 17 | ```js 18 | import { getImageColors } from "@11ty/image-color"; 19 | 20 | // Returns an array of color objects 21 | let colors = await getImageColors("./sample.jpg"); 22 | 23 | // Works with local or remote images 24 | // let colors = await getImageColors("https://example.com/sample.jpg"); 25 | 26 | // Get oklch string values 27 | colors.map(c => c.background); 28 | 29 | // Get hex values 30 | colors.map(c => c.colorjs.toString({format: "hex"})); 31 | 32 | // Filter colors based on Lightness value 33 | colors.filter(c => c.colorjs.oklch.l > .1); 34 | 35 | // Sort Lightest colors first 36 | colors.sort((a, b) => { 37 | return b.colorjs.oklch.l - a.colorjs.oklch.l; 38 | }) 39 | ``` 40 | 41 | Learn more about [color.js Color objects](https://colorjs.io/docs/the-color-object). 42 | 43 | ### Returns 44 | 45 | An array of colors in the image, with the following properties: 46 | 47 | ```js 48 | [{ 49 | background, // oklch color string (you probably want this) 50 | foreground, // accessible color for text on top 51 | 52 | colorjs, // colorjs.io Color object 53 | 54 | mode, // one of "dark" or "light", based on WCAG21 contrast versus #000 or #fff 55 | contrast: { 56 | light, // WCAG21 contrast color (number) versus white (#fff) (4.5+ is good) 57 | dark, // WCAG21 contrast color (number) versus black (#000) (4.5+ is good) 58 | }, 59 | 60 | toString(), // returns same as `background` 61 | }] 62 | ``` 63 | 64 | Learn more about [color.js Color objects](https://colorjs.io/docs/the-color-object). 65 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import Color from "colorjs.io"; 3 | // getImageColors persists to disk, getImageColorsRaw does not 4 | import { getImageColors, getImageColorsRaw } from "../image-color.js"; 5 | 6 | // https://unsplash.com/photos/a-tall-building-with-a-sky-background-I5eKsDa8iwY 7 | test("Tall building", async t => { 8 | let colors = await getImageColorsRaw("./test/test.jpg"); 9 | t.deepEqual(colors.map(c => c.colorjs.toString({format: "hex"})), ["#305e5a", "#94a7b4", "#e0dee1", "#021a1e"]); 10 | }); 11 | 12 | test("Tall building (original prop)", async t => { 13 | let colors = await getImageColorsRaw("./test/test.jpg"); 14 | t.deepEqual(colors.map(c => c.original), ["#305e5a", "#94a7b4", "#e0dee1", "#021a1e"]); 15 | }); 16 | 17 | test("Prop check", async t => { 18 | let colors = await getImageColorsRaw("./test/test.jpg"); 19 | t.true(colors[0].colorjs instanceof Color); 20 | t.is(typeof colors[0].background, "string"); 21 | t.is(typeof colors[0].contrast.dark, "number"); 22 | t.is(typeof colors[0].contrast.light, "number"); 23 | t.is(typeof colors[0].foreground, "string"); 24 | t.is(typeof colors[0].mode, "string"); 25 | t.true(["dark", "light"].includes(colors[0].mode)); 26 | t.is(typeof colors[0].original, "string"); 27 | t.true(colors[0].toString().startsWith("oklch(")); 28 | t.deepEqual(Object.keys(colors[0]).sort(), ["background", "colorjs", "contrast", "foreground", "mode", "original", "toString"]); 29 | }); 30 | 31 | test("toString()", async t => { 32 | let colors = await getImageColorsRaw("./test/test.jpg"); 33 | t.deepEqual(colors.map(c => ""+c), [ 34 | "oklch(44.796% 0.05118 188.19)", 35 | "oklch(71.786% 0.02854 237.76)", 36 | "oklch(90.302% 0.00457 314.8)", 37 | "oklch(20.059% 0.03184 209.56)", 38 | ]); 39 | }); 40 | 41 | test("Filtering", async t => { 42 | let colors = await getImageColorsRaw("./test/test.jpg"); 43 | t.deepEqual(colors.filter(c => { 44 | return c.colorjs.oklch.l > .1 && c.colorjs.oklch.l < .9; 45 | }).map(c => c.original), ["#305e5a", "#94a7b4", "#021a1e"]); 46 | }); 47 | 48 | test("Memoization (raw)", async t => { 49 | let colors1 = getImageColorsRaw("./test/test.jpg"); 50 | let colors2 = getImageColorsRaw("./test/test.jpg"); 51 | t.is(colors1, colors2); 52 | 53 | let results = await Promise.all([colors1, colors2]); 54 | t.is(results[0], results[1]); 55 | }); 56 | 57 | test("Memoization", async t => { 58 | let colors1 = getImageColors("./test/test.jpg"); 59 | let colors2 = getImageColors("./test/test.jpg"); 60 | t.is(colors1, colors2); 61 | 62 | let results = await Promise.all([colors1, colors2]); 63 | t.is(results[0], results[1]); 64 | }); 65 | -------------------------------------------------------------------------------- /image-color.js: -------------------------------------------------------------------------------- 1 | import memoize from "memoize"; 2 | import PQueue from 'p-queue'; 3 | import Color from "colorjs.io"; 4 | import debugUtil from "debug"; 5 | import { PNG } from "pngjs"; 6 | import ndarray from "ndarray"; 7 | import { extractColors } from "extract-colors"; 8 | import Cache from "@11ty/eleventy-fetch"; 9 | import Image from "@11ty/eleventy-img"; 10 | 11 | const debug = debugUtil("Eleventy:ImageColor"); 12 | const queue = new PQueue({ concurrency: 10 }); 13 | 14 | queue.on("active", () => { 15 | debug("Size: %o Pending: %o", queue.size, queue.pending); 16 | }); 17 | 18 | export async function getImage(source) { 19 | return Image(source, { 20 | // PNG is important here 21 | formats: ["png"], 22 | widths: [50], 23 | dryRun: true, 24 | }); 25 | } 26 | 27 | // Thanks to `get-pixels` https://www.npmjs.com/package/get-pixels 28 | async function handlePNG(data) { 29 | var png = new PNG(); 30 | return new Promise((resolve, reject) => { 31 | png.parse(data, function(err, img_data) { 32 | if(err) { 33 | reject(err); 34 | } else { 35 | resolve(ndarray(new Uint8Array(img_data.data), 36 | [img_data.width|0, img_data.height|0, 4], 37 | [4, 4*img_data.width|0, 1], 38 | 0)) 39 | } 40 | }) 41 | }) 42 | } 43 | 44 | // just for backwards compat, doesn’t use disk cache or memoization layer or concurrency queue 45 | export async function getColors(source) { 46 | let stats = await getImage(source); 47 | debug("Image fetched: %o", source); 48 | 49 | return getColorsFromBuffer(stats.png[0].buffer); 50 | } 51 | 52 | async function getColorsFromBuffer(buffer) { 53 | let pixels = await handlePNG(buffer); 54 | let data = [...pixels.data]; 55 | let [width, height] = pixels.shape; 56 | 57 | let colors = await extractColors({ data, width, height }); 58 | 59 | return colors.map(colorData => { 60 | let c = new Color(colorData.hex); 61 | 62 | let contrastDark = c.contrast("#000", "WCAG21"); 63 | let contrastLight = c.contrast("#fff", "WCAG21"); 64 | 65 | let alternate; 66 | let mode = "unknown"; 67 | if(contrastDark > 4.5) { 68 | // contrasts well with #000 69 | alternate = "#000"; // text is black 70 | mode = "light"; 71 | } else if(contrastLight > 4.5) { 72 | // contrasts well with #fff 73 | alternate = "#fff"; // text is white 74 | mode = "dark"; 75 | } 76 | 77 | return { 78 | colorjs: c, 79 | original: colorData.hex, 80 | background: ""+c.to("oklch"), 81 | foreground: alternate, 82 | 83 | mode, 84 | contrast: { 85 | light: contrastLight, 86 | dark: contrastDark, 87 | }, 88 | 89 | toString() { 90 | return ""+c.to("oklch"); 91 | } 92 | } 93 | }).filter(entry => Boolean(entry)); 94 | } 95 | 96 | export function getQueuedFunction(options = {}) { 97 | return memoize(async function(source) { 98 | debug("Fetching: %o", source); 99 | 100 | // This *needs* to be outside of Cache so it doesn’t have conflicting concurrency queues. 101 | let stats = await getImage(source); 102 | debug("Image fetched: %o", source); 103 | 104 | let buffer = stats.png[0].buffer; 105 | 106 | // Add to concurrency queue 107 | return queue.add(() => Cache(async () => { 108 | return getColorsFromBuffer(buffer); 109 | }, Object.assign({ 110 | type: "json", 111 | duration: "1d", 112 | requestId: `11ty/image-color/${source}`, 113 | }, options.cacheOptions)).then(colors => { 114 | // Color instances are not JSON-friendly 115 | for(let c of colors) { 116 | c.colorjs = new Color(c.original); 117 | } 118 | 119 | return colors; 120 | })); 121 | }); 122 | } 123 | 124 | let fn = getQueuedFunction(); 125 | let rawFn = getQueuedFunction({ cacheOptions: { dryRun: true } }); 126 | 127 | export function getImageColors(source) { 128 | return fn(source); 129 | } 130 | 131 | // no disk cache, but keep in-memory memoize (will read from disk if available!) 132 | export function getImageColorsRaw(source) { 133 | return rawFn(source); 134 | } 135 | --------------------------------------------------------------------------------