diff --git a/configs/eslint-config-js/noimport.js b/configs/eslint-config-js/noimport.js index 3b7eac8..732b293 100644 --- a/configs/eslint-config-js/noimport.js +++ b/configs/eslint-config-js/noimport.js @@ -20,5 +20,6 @@ module.exports = { ], rules: { "arrow-body-style": ["warn", "as-needed"], + "newline-before-return": "warn", }, }; diff --git a/packages/image/src/extractColors.spec.ts b/packages/image/src/extractColors.spec.ts deleted file mode 100644 index e85394f..0000000 --- a/packages/image/src/extractColors.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { extractColors } from "."; - -describe("extractColors", () => { - it("should return an array of colors", async () => { - const result = await extractColors("src/images/test.png", { - quality: 10, - }); - expect(result.length).toBeGreaterThan(0); - }); - - it("should return limited number of colors when colorCount is provided", async () => { - const colorCount = 5; - const result = await extractColors("src/images/test.png", { - quality: 10, - colorCount, - }); - expect(result.length).toEqual(colorCount); - }); - - it("should throw an error if quality is out of range", async () => { - await expect( - extractColors("src/images/test.png", { quality: 101 }), - ).rejects.toThrow("Quality should be between 1 and 100"); - await expect( - extractColors("src/images/test.png", { quality: 0 }), - ).rejects.toThrow("Quality should be between 1 and 100"); - }); - - it("should filter out pixels with opacity less than 125", async () => { - const result = await extractColors("src/images/test.png"); - const hasTransparentPixel = result.some(([, , , a]) => a < 125); - expect(hasTransparentPixel).toBe(false); - }); - - it("should filter out almost white pixels", async () => { - const result = await extractColors("src/images/test.png"); - const hasWhitePixel = result.some( - ([r, g, b]) => r > 250 && g > 250 && b > 250, - ); - expect(hasWhitePixel).toBe(false); - }); -}); diff --git a/packages/image/src/extractColors.ts b/packages/image/src/extractColors.ts deleted file mode 100644 index 79fbf5c..0000000 --- a/packages/image/src/extractColors.ts +++ /dev/null @@ -1,76 +0,0 @@ -interface PaletteOptions { - colorCount?: number; - quality?: number; -} - -/** - * Extracts a palette of colors from a given image URL based on provided options. - * @param {string} imageUrl - The URL of the image. - * @param {PaletteOptions} [options] - The extraction options. - */ -export const extractColors = (imageUrl: string, options?: PaletteOptions) => - loadImageFromUrl(imageUrl) - .then(extractImage) - .then((data) => { - if (!data) { - throw new Error( - "Failed to extract pixel data from the image. Ensure the image URL is valid and accessible.", - ); - } - - const pixels = filterRelevantPixels(data, options?.quality); - const count = options?.colorCount || pixels.length; - return pixels.slice(0, count); - }); - -/** - * Extracts pixel data from an image. - * @param {HTMLImageElement} img - The image element. - */ -const extractImage = (img: HTMLImageElement) => { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - - if (!context) { - return null; - } - - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - context.drawImage(img, 0, 0); - - return context.getImageData(0, 0, canvas.width, canvas.height).data; -}; - -/** - * Loads an image from a given URL. - * @param {string} url - The URL of the image to load. - */ -const loadImageFromUrl = (url: string) => - new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.crossOrigin = "Anonymous"; - img.src = url; - }); - -/** - * Filters and extracts relevant pixels from image data based on quality setting. - * @param {Uint8ClampedArray} data - The image pixel data. - * @param {number} [quality=100] - Quality setting for filtering pixels. Higher values mean more pixels are processed. Range: [1, 100]. - */ -const filterRelevantPixels = (data: Uint8ClampedArray, quality = 100) => { - if (quality < 1 || quality > 100) { - throw new Error("Quality should be between 1 and 100"); - } - - const step = Math.ceil(100 / quality); - return Array.from({ length: Math.ceil(data.length / (4 * step)) }) - .map((_, index) => { - const idx = index * 4 * step; - return [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]]; - }) - .filter(([r, g, b, a]) => a >= 125 && !(r > 250 && g > 250 && b > 250)) - .map(([r, g, b]) => [r, g, b]); -}; diff --git a/packages/image/src/extractRGBAs.spec.ts b/packages/image/src/extractRGBAs.spec.ts new file mode 100644 index 0000000..0c8197e --- /dev/null +++ b/packages/image/src/extractRGBAs.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { extractRGBAs } from "."; + +const load = (src: HTMLImageElement["src"]) => + new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(); + image.src = src; + }); + +describe("extractRGBAs", () => { + it("should return an array of colors", async () => { + const image = await load("src/images/test.png"); + const result = extractRGBAs(image, { quality: 10 }); + expect(result.length).toBeGreaterThan(0); + }); + + it("should throw an error if quality is out of range", async () => { + const image = await load("src/images/test.png"); + expect(() => extractRGBAs(image, { quality: 101 })).toThrow( + "options.quality should be between 1 and 100", + ); + expect(() => extractRGBAs(image, { quality: 0 })).toThrow( + "options.quality should be between 1 and 100", + ); + }); +}); diff --git a/packages/image/src/extractRGBAs.ts b/packages/image/src/extractRGBAs.ts new file mode 100644 index 0000000..eea0608 --- /dev/null +++ b/packages/image/src/extractRGBAs.ts @@ -0,0 +1,61 @@ +type RGBA = readonly [number, number, number, number]; +type ExtractRGBAsOptions = { quality: number }; +const initialOptions: ExtractRGBAsOptions = { quality: 100 }; +/** + * Filters and extracts relevant pixels from image data based on quality setting. + */ +export const extractRGBAs = ( + /** + * The image pixel data. + */ + image: HTMLImageElement, + /** + * Quality setting for filtering pixels. Higher values mean more pixels are processed. Range: [1, 100] + */ + options = initialOptions, +) => { + if (options.quality < 1 || options.quality > 100) { + throw new Error("options.quality should be between 1 and 100"); + } + const uint8ClampedArray = extractUint8ClampedArray(image); + const step = Math.ceil(100 / options.quality); + + return Array.from({ + length: Math.ceil(uint8ClampedArray.length / (4 * step)), + }).map((_, index) => { + const rIndex = index * 4 * step; + + return [ + uint8ClampedArray[rIndex], + uint8ClampedArray[rIndex + 1], + uint8ClampedArray[rIndex + 2], + uint8ClampedArray[rIndex + 3], + ] as RGBA; + }); +}; + +/** + * Extracts pixel data from an image. + */ +const extractUint8ClampedArray = ( + /** + * The image element + */ + image: HTMLImageElement, +) => { + const canvas = document.createElement("canvas"); + const canvasRenderingContext2D = canvas.getContext("2d"); + if (!canvasRenderingContext2D) { + throw new Error("canvasRenderingContext2D is not supported"); + } + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + canvasRenderingContext2D.drawImage(image, 0, 0); + + return canvasRenderingContext2D.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ).data; +}; diff --git a/packages/image/src/index.ts b/packages/image/src/index.ts index 9420a9e..144c379 100644 --- a/packages/image/src/index.ts +++ b/packages/image/src/index.ts @@ -1,4 +1,4 @@ export { checkWebPSupport } from "./checkWebPSupport"; export { detect } from "./detect"; -export { extractColors } from "./extractColors"; +export { extractRGBAs } from "./extractRGBAs"; export { load, type ImageSource } from "./load";