Comparing version 0.0.2 to 0.2.0
@@ -28,5 +28,5 @@ "use strict"; | ||
const regression = [ | ||
linearRegression_1.linearRegression(y, colors[0]), | ||
linearRegression_1.linearRegression(y, colors[1]), | ||
linearRegression_1.linearRegression(y, colors[2]), | ||
(0, linearRegression_1.linearRegression)(y, colors[0]), | ||
(0, linearRegression_1.linearRegression)(y, colors[1]), | ||
(0, linearRegression_1.linearRegression)(y, colors[2]), | ||
]; | ||
@@ -40,3 +40,3 @@ const start = sampleColor(regression, 0); | ||
return regression | ||
.map((r) => (util_1.clamp(r.intercept + r.slope * t, 0, 255)) | 0); | ||
.map((r) => ((0, util_1.clamp)(r.intercept + r.slope * t, 0, 255)) | 0); | ||
} |
@@ -5,3 +5,3 @@ "use strict"; | ||
function hexString(n) { | ||
return Math.max(0, Math.min(255, n | 0)).toString(16).padStart(2, '0'); | ||
return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); | ||
} | ||
@@ -8,0 +8,0 @@ exports.hexString = hexString; |
@@ -13,5 +13,4 @@ "use strict"; | ||
const canvas = getCanvas(); | ||
const Img = HTMLImageElement; | ||
const width = image instanceof Img ? image.naturalWidth : +image.width; | ||
const height = image instanceof Img ? image.naturalHeight : +image.height; | ||
const width = image instanceof HTMLImageElement ? image.naturalWidth : +image.width; | ||
const height = image instanceof HTMLImageElement ? image.naturalHeight : +image.height; | ||
const scale = maxDimension | ||
@@ -27,2 +26,3 @@ ? Math.min(maxDimension / Math.max(width, height), 1.0) | ||
throw new Error('getContext failed'); | ||
ctx.imageSmoothingQuality = 'low'; | ||
ctx.drawImage(image, 0, 0, outputWidth, outputHeight); | ||
@@ -29,0 +29,0 @@ return ctx.getImageData(0, 0, outputWidth, outputHeight); |
"use strict"; | ||
// import mcgRandom from './mcgRandom'; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.kmeans = void 0; | ||
const mcgRandom_1 = require("./mcgRandom"); | ||
const channels = 3; | ||
// sadly this is about twice as fast as x ** 2. | ||
const squared = (x) => x * x; | ||
// function selectClusterAtRandom(image: ImageData, random: () => number): Cluster { | ||
// const i = ((random() * image.width * image.height) | 0) * channels; | ||
// const { data } = image; | ||
// return { | ||
// x: data[i], | ||
// y: data[i + 1], | ||
// z: data[i + 2], | ||
// count: 0, | ||
// }; | ||
// } | ||
// This function also operates without doing any gamma correction. | ||
@@ -13,18 +26,21 @@ // I haven't evaluated the effect of this on the output. | ||
// Or it's just an excuse to save a few cycles. ¯\_(ツ)_/¯ | ||
function kmeans(image, k, iterations) { | ||
const random = mcgRandom_1.default(); | ||
const random255 = () => random() * 255; | ||
if (k < 1) | ||
throw new Error('k must be greater than 0'); | ||
const { data } = image; | ||
function kmeans(data, initialClusters, iterations) { | ||
// const { data } = image; | ||
const clusters = []; | ||
const nextClusters = []; | ||
for (let i = 0; i < k; i++) { | ||
// (histogramColors(image, k)).forEach(([r, g, b]) => { | ||
// const { l: x, a: y, b: z } = srgbToLab({ r: r / 255, g: g / 255, b: b / 255 }); | ||
// clusters.push({ | ||
// x, y, z, count: 0, | ||
// }); | ||
// nextClusters.push({ | ||
// x: 0, y: 0, z: 0, count: 0, | ||
// }); | ||
// }); | ||
initialClusters.forEach((point) => { | ||
nextClusters.push({ ...point, count: 1 }); | ||
clusters.push({ | ||
x: random255(), y: random255(), z: random255(), count: 0, | ||
}); | ||
nextClusters.push({ | ||
x: 0, y: 0, z: 0, count: 0, | ||
}); | ||
} | ||
}); | ||
for (let i = 0; i < iterations; i++) { | ||
@@ -34,13 +50,6 @@ clusters.forEach((cluster, j) => { | ||
const nextCluster = nextClusters[j]; | ||
if (nextCluster.count > 0) { | ||
cluster.x = nextCluster.x / nextCluster.count; | ||
cluster.y = nextCluster.y / nextCluster.count; | ||
cluster.z = nextCluster.z / nextCluster.count; | ||
} | ||
else { | ||
// give empty clusters another chance | ||
cluster.x = random255(); | ||
cluster.y = random255(); | ||
cluster.z = random255(); | ||
} | ||
const count = Math.max(nextCluster.count, 1); | ||
cluster.x = nextCluster.x / count; | ||
cluster.y = nextCluster.y / count; | ||
cluster.z = nextCluster.z / count; | ||
cluster.count = nextCluster.count; | ||
@@ -53,12 +62,15 @@ // reset next clusters | ||
}); | ||
for (let p = 0; p < data.length; p += 4) { | ||
let closestClusterDistance = (data[p] - clusters[0].x) ** 2 | ||
+ (data[p + 1] - clusters[0].y) ** 2 | ||
+ (data[p + 2] - clusters[0].z) ** 2; | ||
let closestCluster = nextClusters[0]; | ||
for (let p = 0; p < data.length; p += 3) { | ||
const x = data[p]; | ||
const y = data[p + 1]; | ||
const z = data[p + 2]; | ||
let closestClusterDistance = Infinity; | ||
let closestCluster = clusters[0]; | ||
// find the closest cluster | ||
for (let ci = 1; ci < clusters.length; ci++) { | ||
const distance = (data[p] - clusters[ci].x) ** 2 | ||
+ (data[p + 1] - clusters[ci].y) ** 2 | ||
+ (data[p + 2] - clusters[ci].z) ** 2; | ||
for (let ci = 0; ci < clusters.length; ci++) { | ||
// since this is just used in a comparison the sqrt is not needed | ||
const cluster = clusters[ci]; | ||
const distance = squared(x - cluster.x) | ||
+ squared(y - cluster.y) | ||
+ squared(z - cluster.z); | ||
if (distance < closestClusterDistance) { | ||
@@ -71,5 +83,5 @@ closestClusterDistance = distance; | ||
closestCluster.count += 1; | ||
closestCluster.x += data[p]; | ||
closestCluster.y += data[p + 1]; | ||
closestCluster.z += data[p + 2]; | ||
closestCluster.x += x; | ||
closestCluster.y += y; | ||
closestCluster.z += z; | ||
} | ||
@@ -76,0 +88,0 @@ } |
@@ -8,8 +8,9 @@ export { getImageData } from './getImageData'; | ||
* @param image the image to extract the palette from. | ||
* Will be scaled down to at most 32x32. | ||
* Must be loaded/complete. | ||
* @param numberOfColors upper limit on the number of colors to be returned | ||
* @param fast if true the image will be downscaled to 64x64, 128x128 otherwise. | ||
* The precise sizes used may change in the future. | ||
* @returns representative colors of the image ordered by importance (size of the cluster) | ||
*/ | ||
export declare function getPalette(image: CanvasImageSource, numberOfColors?: number): string[]; | ||
export declare function getPalette(image: CanvasImageSource, numberOfColors?: number, fast?: boolean): string[]; | ||
/** | ||
@@ -16,0 +17,0 @@ * Extract representative colors from image data. |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.fitGradientToImageData = exports.fitGradient = exports.getPaletteFromImageData = exports.getPalette = exports.getImageData = void 0; | ||
const kmeans_1 = require("./kmeans"); | ||
const fitGradient_1 = require("./fitGradient"); | ||
const getImageData_1 = require("./getImageData"); | ||
const format_1 = require("./format"); | ||
const getPalette_1 = require("./getPalette"); | ||
var getImageData_2 = require("./getImageData"); | ||
@@ -16,10 +16,11 @@ Object.defineProperty(exports, "getImageData", { enumerable: true, get: function () { return getImageData_2.getImageData; } }); | ||
* @param image the image to extract the palette from. | ||
* Will be scaled down to at most 32x32. | ||
* Must be loaded/complete. | ||
* @param numberOfColors upper limit on the number of colors to be returned | ||
* @param fast if true the image will be downscaled to 64x64, 128x128 otherwise. | ||
* The precise sizes used may change in the future. | ||
* @returns representative colors of the image ordered by importance (size of the cluster) | ||
*/ | ||
// integration tested only | ||
function getPalette(image, numberOfColors = 4) { | ||
const imageData = getImageData_1.getImageData(image, 32); | ||
function getPalette(image, numberOfColors = 4, fast = false) { | ||
const imageData = (0, getImageData_1.getImageData)(image, fast ? 64 : 128); | ||
return getPaletteFromImageData(imageData, numberOfColors); | ||
@@ -36,7 +37,4 @@ } | ||
function getPaletteFromImageData(imageData, numberOfColors = 4) { | ||
const colors = kmeans_1.kmeans(imageData, numberOfColors, 16) | ||
.filter((c) => c.count > 0) | ||
.sort((a, b) => b.count - a.count) | ||
.map((c) => [c.x, c.y, c.z]); | ||
return colors.map(format_1.hexColorString); | ||
return (0, getPalette_1.getPalette)(imageData, numberOfColors) | ||
.map(format_1.hexColorString); | ||
} | ||
@@ -56,3 +54,3 @@ exports.getPaletteFromImageData = getPaletteFromImageData; | ||
function fitGradient(image) { | ||
const imageData = getImageData_1.getImageData(image, 32); | ||
const imageData = (0, getImageData_1.getImageData)(image, 32); | ||
return fitGradientToImageData(imageData); | ||
@@ -67,6 +65,6 @@ } | ||
function fitGradientToImageData(imageData) { | ||
const colors = fitGradient_1.fitGradient(imageData) | ||
const colors = (0, fitGradient_1.fitGradient)(imageData) | ||
.map(format_1.hexColorString); | ||
return format_1.linearGradient(colors); | ||
return (0, format_1.linearGradient)(colors); | ||
} | ||
exports.fitGradientToImageData = fitGradientToImageData; |
export function hexString(n) { | ||
return Math.max(0, Math.min(255, n | 0)).toString(16).padStart(2, '0'); | ||
return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); | ||
} | ||
@@ -4,0 +4,0 @@ export function hexColorString(color) { |
@@ -10,5 +10,4 @@ // this file is only covered by the end to end browser tests | ||
const canvas = getCanvas(); | ||
const Img = HTMLImageElement; | ||
const width = image instanceof Img ? image.naturalWidth : +image.width; | ||
const height = image instanceof Img ? image.naturalHeight : +image.height; | ||
const width = image instanceof HTMLImageElement ? image.naturalWidth : +image.width; | ||
const height = image instanceof HTMLImageElement ? image.naturalHeight : +image.height; | ||
const scale = maxDimension | ||
@@ -24,4 +23,5 @@ ? Math.min(maxDimension / Math.max(width, height), 1.0) | ||
throw new Error('getContext failed'); | ||
ctx.imageSmoothingQuality = 'low'; | ||
ctx.drawImage(image, 0, 0, outputWidth, outputHeight); | ||
return ctx.getImageData(0, 0, outputWidth, outputHeight); | ||
} |
@@ -1,2 +0,15 @@ | ||
import mcgRandom from './mcgRandom.js'; | ||
// import mcgRandom from './mcgRandom.js'; | ||
const channels = 3; | ||
// sadly this is about twice as fast as x ** 2. | ||
const squared = (x) => x * x; | ||
// function selectClusterAtRandom(image: ImageData, random: () => number): Cluster { | ||
// const i = ((random() * image.width * image.height) | 0) * channels; | ||
// const { data } = image; | ||
// return { | ||
// x: data[i], | ||
// y: data[i + 1], | ||
// z: data[i + 2], | ||
// count: 0, | ||
// }; | ||
// } | ||
// This function also operates without doing any gamma correction. | ||
@@ -10,18 +23,21 @@ // I haven't evaluated the effect of this on the output. | ||
// Or it's just an excuse to save a few cycles. ¯\_(ツ)_/¯ | ||
export function kmeans(image, k, iterations) { | ||
const random = mcgRandom(); | ||
const random255 = () => random() * 255; | ||
if (k < 1) | ||
throw new Error('k must be greater than 0'); | ||
const { data } = image; | ||
export function kmeans(data, initialClusters, iterations) { | ||
// const { data } = image; | ||
const clusters = []; | ||
const nextClusters = []; | ||
for (let i = 0; i < k; i++) { | ||
// (histogramColors(image, k)).forEach(([r, g, b]) => { | ||
// const { l: x, a: y, b: z } = srgbToLab({ r: r / 255, g: g / 255, b: b / 255 }); | ||
// clusters.push({ | ||
// x, y, z, count: 0, | ||
// }); | ||
// nextClusters.push({ | ||
// x: 0, y: 0, z: 0, count: 0, | ||
// }); | ||
// }); | ||
initialClusters.forEach((point) => { | ||
nextClusters.push({ ...point, count: 1 }); | ||
clusters.push({ | ||
x: random255(), y: random255(), z: random255(), count: 0, | ||
}); | ||
nextClusters.push({ | ||
x: 0, y: 0, z: 0, count: 0, | ||
}); | ||
} | ||
}); | ||
for (let i = 0; i < iterations; i++) { | ||
@@ -31,13 +47,6 @@ clusters.forEach((cluster, j) => { | ||
const nextCluster = nextClusters[j]; | ||
if (nextCluster.count > 0) { | ||
cluster.x = nextCluster.x / nextCluster.count; | ||
cluster.y = nextCluster.y / nextCluster.count; | ||
cluster.z = nextCluster.z / nextCluster.count; | ||
} | ||
else { | ||
// give empty clusters another chance | ||
cluster.x = random255(); | ||
cluster.y = random255(); | ||
cluster.z = random255(); | ||
} | ||
const count = Math.max(nextCluster.count, 1); | ||
cluster.x = nextCluster.x / count; | ||
cluster.y = nextCluster.y / count; | ||
cluster.z = nextCluster.z / count; | ||
cluster.count = nextCluster.count; | ||
@@ -50,12 +59,15 @@ // reset next clusters | ||
}); | ||
for (let p = 0; p < data.length; p += 4) { | ||
let closestClusterDistance = (data[p] - clusters[0].x) ** 2 | ||
+ (data[p + 1] - clusters[0].y) ** 2 | ||
+ (data[p + 2] - clusters[0].z) ** 2; | ||
let closestCluster = nextClusters[0]; | ||
for (let p = 0; p < data.length; p += 3) { | ||
const x = data[p]; | ||
const y = data[p + 1]; | ||
const z = data[p + 2]; | ||
let closestClusterDistance = Infinity; | ||
let closestCluster = clusters[0]; | ||
// find the closest cluster | ||
for (let ci = 1; ci < clusters.length; ci++) { | ||
const distance = (data[p] - clusters[ci].x) ** 2 | ||
+ (data[p + 1] - clusters[ci].y) ** 2 | ||
+ (data[p + 2] - clusters[ci].z) ** 2; | ||
for (let ci = 0; ci < clusters.length; ci++) { | ||
// since this is just used in a comparison the sqrt is not needed | ||
const cluster = clusters[ci]; | ||
const distance = squared(x - cluster.x) | ||
+ squared(y - cluster.y) | ||
+ squared(z - cluster.z); | ||
if (distance < closestClusterDistance) { | ||
@@ -68,5 +80,5 @@ closestClusterDistance = distance; | ||
closestCluster.count += 1; | ||
closestCluster.x += data[p]; | ||
closestCluster.y += data[p + 1]; | ||
closestCluster.z += data[p + 2]; | ||
closestCluster.x += x; | ||
closestCluster.y += y; | ||
closestCluster.z += z; | ||
} | ||
@@ -73,0 +85,0 @@ } |
@@ -8,8 +8,9 @@ export { getImageData } from './getImageData'; | ||
* @param image the image to extract the palette from. | ||
* Will be scaled down to at most 32x32. | ||
* Must be loaded/complete. | ||
* @param numberOfColors upper limit on the number of colors to be returned | ||
* @param fast if true the image will be downscaled to 64x64, 128x128 otherwise. | ||
* The precise sizes used may change in the future. | ||
* @returns representative colors of the image ordered by importance (size of the cluster) | ||
*/ | ||
export declare function getPalette(image: CanvasImageSource, numberOfColors?: number): string[]; | ||
export declare function getPalette(image: CanvasImageSource, numberOfColors?: number, fast?: boolean): string[]; | ||
/** | ||
@@ -16,0 +17,0 @@ * Extract representative colors from image data. |
@@ -1,5 +0,5 @@ | ||
import { kmeans } from './kmeans.js'; | ||
import { fitGradient as fitGradientImplementation } from './fitGradient.js'; | ||
import { getImageData } from './getImageData.js'; | ||
import { hexColorString, linearGradient } from './format.js'; | ||
import { getPalette as getPaletteImplemention } from './getPalette.js'; | ||
export { getImageData } from './getImageData.js'; | ||
@@ -12,10 +12,11 @@ /** | ||
* @param image the image to extract the palette from. | ||
* Will be scaled down to at most 32x32. | ||
* Must be loaded/complete. | ||
* @param numberOfColors upper limit on the number of colors to be returned | ||
* @param fast if true the image will be downscaled to 64x64, 128x128 otherwise. | ||
* The precise sizes used may change in the future. | ||
* @returns representative colors of the image ordered by importance (size of the cluster) | ||
*/ | ||
// integration tested only | ||
export function getPalette(image, numberOfColors = 4) { | ||
const imageData = getImageData(image, 32); | ||
export function getPalette(image, numberOfColors = 4, fast = false) { | ||
const imageData = getImageData(image, fast ? 64 : 128); | ||
return getPaletteFromImageData(imageData, numberOfColors); | ||
@@ -31,7 +32,4 @@ } | ||
export function getPaletteFromImageData(imageData, numberOfColors = 4) { | ||
const colors = kmeans(imageData, numberOfColors, 16) | ||
.filter((c) => c.count > 0) | ||
.sort((a, b) => b.count - a.count) | ||
.map((c) => [c.x, c.y, c.z]); | ||
return colors.map(hexColorString); | ||
return getPaletteImplemention(imageData, numberOfColors) | ||
.map(hexColorString); | ||
} | ||
@@ -38,0 +36,0 @@ /** |
{ | ||
"name": "dont-crop", | ||
"version": "0.0.2", | ||
"version": "0.2.0", | ||
"description": "A library to fit gradients to images and extract it's dominant colors to help you avoid cropping images.", | ||
@@ -25,3 +25,4 @@ "main": "./dist/cjs/lib.js", | ||
"prepare": "scripts/prepare.sh", | ||
"endToEndTest": "./tests/endToEndTest.sh" | ||
"endToEndTest": "./tests/endToEndTest.sh", | ||
"benchmark": "ts-node scripts/benchmark.ts" | ||
}, | ||
@@ -32,3 +33,3 @@ "author": "Jonas Wagner", | ||
"@types/benchmark": "^2.1.0", | ||
"@types/jest": "^26.0.23", | ||
"@types/jest": "^27.0.1", | ||
"@types/puppeteer": "^5.4.3", | ||
@@ -42,2 +43,3 @@ "@types/react": "^17.0.9", | ||
"benchmark": "^2.1.4", | ||
"canvas": "^2.8.0", | ||
"css-loader": "^5.2.6", | ||
@@ -53,13 +55,13 @@ "eslint": "^7.23.0", | ||
"serve-static": "^1.14.1", | ||
"sharp": "^0.28.3", | ||
"style-loader": "^2.0.0", | ||
"sharp": "^0.29.0", | ||
"style-loader": "^3.2.1", | ||
"ts-jest": "^27.0.1", | ||
"ts-loader": "^9.2.2", | ||
"ts-node": "^10.0.0", | ||
"typedoc": "^0.20.36", | ||
"typescript": "^4.2.4", | ||
"typedoc": "^0.21.9", | ||
"typescript": "^4.4.2", | ||
"webpack": "^5.38.1", | ||
"webpack-cli": "^4.7.0", | ||
"webpack-dev-server": "^3.11.2" | ||
"webpack-dev-server": "^4.1.0" | ||
} | ||
} |
@@ -11,5 +11,15 @@ <img src="docs/logo.png" width="400" /> | ||
![lead image](docs/lead-lossless.webp) | ||
## Examples | ||
### fitGradient() | ||
![fitGradient](docs/fitGradient.webp) | ||
Photo by [Abed Ismail](https://unsplash.com/photos/fZXZ1-hbFrY) | ||
### getPalette() | ||
![getPalette](docs/getPalette.webp) | ||
### More Examples | ||
View the [demo page](https://29a.ch/sandbox/2021/dont-crop/) to see more examples and experiment with your own images. | ||
## Installation | ||
@@ -72,6 +82,15 @@ ``` | ||
When using `fitGradient` only and bundling your code using webpack 5 dont-crop will add about **1.2 kb** to your bundle size. | ||
`getPalette` will cost you a bit more than **1.4 kb**. | ||
You can use both for about **2.1 kb**. | ||
When using `fitGradient` only and bundling your code using webpack 5 dont-crop will add about **1.2 kb** (0.7 gzipped) to your bundle size. | ||
`getPalette` will cost you a bit more than **3.2 kb** (1.7 gzipped). | ||
You can use both for about **4 kb** (2 gzipped). | ||
``` | ||
3925 dist/both.js | ||
1911 dist/both.js.gz | ||
1264 dist/fitGradient.js | ||
710 dist/fitGradient.js.gz | ||
3261 dist/getPalette.js | ||
1656 dist/getPalette.js.gz | ||
``` | ||
Runtime performance is also fast enough not to worry about. | ||
@@ -81,8 +100,9 @@ | ||
# on a AMD Ryzen 9 5950X | ||
fitGradientToImageData x 43,559 ops/sec ±0.40% (97 runs sampled) | ||
getPaletteFromImageData x 5,420 ops/sec ±0.21% (92 runs sampled) | ||
fitGradientToImageData x 19,813 ops/sec ±0.96% (97 runs sampled) | ||
getPaletteFromImageData(fast=false) x 156 ops/sec ±0.66% (83 runs sampled) | ||
getPaletteFromImageData(fast=true) x 645 ops/sec ±0.17% (97 runs sampled) | ||
``` | ||
The versions of the functions operating on images rather than the already downscaled image data are slower. | ||
Their performance depends on the exact browser and device in question as well but it should generally be in the ballpark of few milliseconds for reasonably sized images. | ||
Their performance depends on the exact browser and device in question as well but it should generally be in the ballpark of a few milliseconds for reasonably sized images. | ||
@@ -93,7 +113,16 @@ ## Test Coverage | ||
## Algorithms | ||
Glad you asked. `fitGradient()` is using simple [linear regression](https://en.wikipedia.org/wiki/Linear_regression). | ||
`getPallete()` is based on [k-means](https://en.wikipedia.org/wiki/K-means_clustering). | ||
`getPalete()` is based on [k-means](https://en.wikipedia.org/wiki/K-means_clustering). | ||
The initial clusters are chosen using a histogram. | ||
Similar clusters in the result are merged in a post processing step. | ||
This is necessary because k-means tends to return equally sized clusters | ||
whereas getPalette is supposed to return distinct clusters. | ||
The merging is tuned to preserve different hues and colors rather than returning the most prominent shades of color (which might all share a similar hue). | ||
The processing happens in the CIE Lab color space using CIE76 ΔE*. | ||
## Alternatives | ||
@@ -114,2 +143,7 @@ | ||
From a quick looks it seems to be using median-cut which will likely yield a bit better results than the simplistic k-means used here. | ||
### [fast-average-color](https://github.com/fast-average-color/fast-average-color) | ||
Returns a single average or dominant color color. | ||
@@ -125,12 +159,10 @@ ### [smartcrop.js](https://github.com/jwagner/smartcrop.js) | ||
* Adding creative controls over colors (max saturation, max lightness, min lightness) | ||
* Grouping of colors (saturated, muted, light, dark, warm, cold) | ||
* Tuning of the variables involved in palette extraction potentially allowing some degree of tweaking by the user of the library | ||
* Weighting the linear-regression and k-means to focus on the center or edges | ||
* Using a more robust regression variation like Theil-Senn | ||
* Gamma corrected linear gradients by manually interpolating the stops | ||
* Something better than straight k-means for palette extraction | ||
All of these would of course add complexity (and size) as well, so for now it's the simplest thing that could possibly work. | ||
## License | ||
MIT |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
46147
941
162
31
30
1