mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
refactor: parse hex color
This commit is contained in:
@@ -110,12 +110,15 @@ round-семантика `clampByte` и trunc-семантика `clampInt` со
|
|||||||
переведён на общий `clamp` через `?? -Infinity / ?? Infinity`. Копия в
|
переведён на общий `clamp` через `?? -Infinity / ?? Infinity`. Копия в
|
||||||
`palette.test.ts` оставлена — тесты независимы.
|
`palette.test.ts` оставлена — тесты независимы.
|
||||||
|
|
||||||
### M8. Два параллельных hex-парсера
|
### M8. Два параллельных hex-парсера — СДЕЛАНО (2026-10)
|
||||||
|
|
||||||
`parseHex` (`core/alpha.ts:147`, tuple, бросает `ToolError`) и `hexToRgb`
|
Единый парсер — `parseHexColor(hex): Rgb` в `core/palette.ts` (терпимый: `#?`,
|
||||||
(`core/palette.ts:8`, объект `{r,g,b}`). Половина core-модулей использует первый
|
3/6 цифр, бросает `ToolError`). `hexToRgb` удалён; `parseHex` из `core/alpha.ts`
|
||||||
(`affine.ts:31`, `geometry.ts:20`, `morphology.ts:123,150`), другая — второй
|
удалён, потребители (`affine`, `background`, `geometry`, `morphology`, `alpha`)
|
||||||
(`masks.ts:25,98`, `pixel-fx.ts:195`, `registry/tools/generate.ts:36`).
|
переведены на объектную деструктуризацию и импорт из `palette`. Тест `parseHex`
|
||||||
|
переехал из `alpha.test.ts` в `palette.test.ts`. Поведенческое послабление:
|
||||||
|
бывшие потребители `hexToRgb` теперь принимают и 3-значный hex — вход всё равно
|
||||||
|
проходит санитизацию схемы.
|
||||||
|
|
||||||
### M9. Гард `localStorage` продублирован 4 раза — СДЕЛАНО (2026-10)
|
### M9. Гард `localStorage` продублирован 4 раза — СДЕЛАНО (2026-10)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseHex } from "./alpha";
|
import { parseHexColor } from "./palette";
|
||||||
import { ToolError } from "./errors";
|
import { ToolError } from "./errors";
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
||||||
import { sampleBilinear } from "./geometry";
|
import { sampleBilinear } from "./geometry";
|
||||||
@@ -28,7 +28,7 @@ export function transformImage(
|
|||||||
): PixelImage {
|
): PixelImage {
|
||||||
const [a, b, c, d, e, f] = dstToSrc;
|
const [a, b, c, d, e, f] = dstToSrc;
|
||||||
const out = createPixelImage(outWidth, outHeight);
|
const out = createPixelImage(outWidth, outHeight);
|
||||||
const bg = bgHex ? parseHex(bgHex) : null;
|
const bg = bgHex ? parseHexColor(bgHex) : null;
|
||||||
for (let y = 0; y < outHeight; y++) {
|
for (let y = 0; y < outHeight; y++) {
|
||||||
for (let x = 0; x < outWidth; x++) {
|
for (let x = 0; x < outWidth; x++) {
|
||||||
const sx = a * x + c * y + e;
|
const sx = a * x + c * y + e;
|
||||||
@@ -41,9 +41,9 @@ export function transformImage(
|
|||||||
sy > img.height - 1 + EPS
|
sy > img.height - 1 + EPS
|
||||||
) {
|
) {
|
||||||
if (bg) {
|
if (bg) {
|
||||||
out.data[di] = bg[0];
|
out.data[di] = bg.r;
|
||||||
out.data[di + 1] = bg[1];
|
out.data[di + 1] = bg.g;
|
||||||
out.data[di + 2] = bg[2];
|
out.data[di + 2] = bg.b;
|
||||||
out.data[di + 3] = 255;
|
out.data[di + 3] = 255;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -51,9 +51,9 @@ export function transformImage(
|
|||||||
const [r, g, bl, al] = sampleBilinear(img, sx, sy);
|
const [r, g, bl, al] = sampleBilinear(img, sx, sy);
|
||||||
if (bg) {
|
if (bg) {
|
||||||
const sa = al / 255;
|
const sa = al / 255;
|
||||||
out.data[di] = r * sa + bg[0] * (1 - sa);
|
out.data[di] = r * sa + bg.r * (1 - sa);
|
||||||
out.data[di + 1] = g * sa + bg[1] * (1 - sa);
|
out.data[di + 1] = g * sa + bg.g * (1 - sa);
|
||||||
out.data[di + 2] = bl * sa + bg[2] * (1 - sa);
|
out.data[di + 2] = bl * sa + bg.b * (1 - sa);
|
||||||
out.data[di + 3] = 255;
|
out.data[di + 3] = 255;
|
||||||
} else {
|
} else {
|
||||||
out.data[di] = r;
|
out.data[di] = r;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
flattenOntoColor,
|
flattenOntoColor,
|
||||||
hardenAlpha,
|
hardenAlpha,
|
||||||
invertAlpha,
|
invertAlpha,
|
||||||
parseHex,
|
|
||||||
removeColorToAlpha,
|
removeColorToAlpha,
|
||||||
roundCorners,
|
roundCorners,
|
||||||
setAlphaChannel,
|
setAlphaChannel,
|
||||||
@@ -171,15 +170,3 @@ describe("flattenOntoColor", () => {
|
|||||||
expect([...out.data]).toEqual([132, 137, 142, 255]);
|
expect([...out.data]).toEqual([132, 137, 142, 255]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseHex", () => {
|
|
||||||
it("parses #rrggbb, rrggbb, #rgb", () => {
|
|
||||||
expect(parseHex("#ff8040")).toEqual([255, 128, 64]);
|
|
||||||
expect(parseHex("ff8040")).toEqual([255, 128, 64]);
|
|
||||||
expect(parseHex("#F80")).toEqual([255, 136, 0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["zzz", "12345", "##ff", ""])('throws on "%s"', (bad) => {
|
|
||||||
expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createPixelImage, type PixelImage } from "./types";
|
import { createPixelImage, type PixelImage } from "./types";
|
||||||
import { ToolError } from "./errors";
|
import { ToolError } from "./errors";
|
||||||
import { clamp } from "./math";
|
import { clamp } from "./math";
|
||||||
|
import { parseHexColor } from "./palette";
|
||||||
|
|
||||||
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ export function removeColorToAlpha(
|
|||||||
hex: string,
|
hex: string,
|
||||||
tolerancePercent = 0,
|
tolerancePercent = 0,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const [targetR, targetG, targetB] = parseHex(hex);
|
const { r: targetR, g: targetG, b: targetB } = parseHexColor(hex);
|
||||||
const tolerance =
|
const tolerance =
|
||||||
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
||||||
const thresholdSq = tolerance * tolerance;
|
const thresholdSq = tolerance * tolerance;
|
||||||
@@ -113,7 +114,7 @@ export function colorMask(
|
|||||||
hex: string,
|
hex: string,
|
||||||
tolerancePercent = 0,
|
tolerancePercent = 0,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const [targetR, targetG, targetB] = parseHex(hex);
|
const { r: targetR, g: targetG, b: targetB } = parseHexColor(hex);
|
||||||
const tolerance =
|
const tolerance =
|
||||||
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
||||||
const thresholdSq = tolerance * tolerance;
|
const thresholdSq = tolerance * tolerance;
|
||||||
@@ -132,7 +133,7 @@ export function colorMask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
|
export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
|
||||||
const [bgR, bgG, bgB] = parseHex(hex);
|
const { r: bgR, g: bgG, b: bgB } = parseHexColor(hex);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
const a = img.data[i + 3] / 255;
|
const a = img.data[i + 3] / 255;
|
||||||
@@ -144,23 +145,3 @@ export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseHex(hex: string): [number, number, number] {
|
|
||||||
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
||||||
if (!match) {
|
|
||||||
throw new ToolError("errors.badHex", { value: hex });
|
|
||||||
}
|
|
||||||
const digits = match[1];
|
|
||||||
if (digits.length === 3) {
|
|
||||||
return [
|
|
||||||
parseInt(digits[0] + digits[0], 16),
|
|
||||||
parseInt(digits[1] + digits[1], 16),
|
|
||||||
parseInt(digits[2] + digits[2], 16),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
parseInt(digits.slice(0, 2), 16),
|
|
||||||
parseInt(digits.slice(2, 4), 16),
|
|
||||||
parseInt(digits.slice(4, 6), 16),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseHex } from "./alpha";
|
import { parseHexColor } from "./palette";
|
||||||
import { createPixelImage, type PixelImage } from "./types";
|
import { createPixelImage, type PixelImage } from "./types";
|
||||||
import { clamp } from "./math";
|
import { clamp } from "./math";
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ export function backgroundRemovalMask(
|
|||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
options: BackgroundOptions,
|
options: BackgroundOptions,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const [tr, tg, tb] = parseHex(options.color);
|
const { r: tr, g: tg, b: tb } = parseHexColor(options.color);
|
||||||
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
|
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
|
||||||
if (options.outerOnly) {
|
if (options.outerOnly) {
|
||||||
floodFromBorders(mask, img.width, img.height);
|
floodFromBorders(mask, img.width, img.height);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { hexToRgb } from "./palette";
|
import { parseHexColor } from "./palette";
|
||||||
import { renderSpace, SPACES } from "./channels";
|
import { renderSpace, SPACES } from "./channels";
|
||||||
import { makeImage } from "./test-helpers";
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe("space conversions", () => {
|
describe("space conversions", () => {
|
||||||
it("hsl of red: h=0, s=1, l=0.5", () => {
|
it("hsl of red: h=0, s=1, l=0.5", () => {
|
||||||
const [h, s, l] = SPACES.hsl.convert(hexToRgb("#ff0000"));
|
const [h, s, l] = SPACES.hsl.convert(parseHexColor("#ff0000"));
|
||||||
expect(h).toBeCloseTo(0);
|
expect(h).toBeCloseTo(0);
|
||||||
expect(s).toBeCloseTo(1);
|
expect(s).toBeCloseTo(1);
|
||||||
expect(l).toBeCloseTo(0.5);
|
expect(l).toBeCloseTo(0.5);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseHex } from "./alpha";
|
import { parseHexColor } from "./palette";
|
||||||
import { ToolError } from "./errors";
|
import { ToolError } from "./errors";
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
||||||
import { clampInt } from "./math";
|
import { clampInt } from "./math";
|
||||||
@@ -18,7 +18,7 @@ export function expandCanvas(
|
|||||||
const b = Math.max(0, Math.trunc(bottom));
|
const b = Math.max(0, Math.trunc(bottom));
|
||||||
const out = createPixelImage(img.width + l + r, img.height + t + b);
|
const out = createPixelImage(img.width + l + r, img.height + t + b);
|
||||||
if (backgroundHex !== undefined && backgroundAlpha > 0) {
|
if (backgroundHex !== undefined && backgroundAlpha > 0) {
|
||||||
const [cr, cg, cb] = parseHex(backgroundHex);
|
const { r: cr, g: cg, b: cb } = parseHexColor(backgroundHex);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
out.data[i] = cr;
|
out.data[i] = cr;
|
||||||
out.data[i + 1] = cg;
|
out.data[i + 1] = cg;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { hexToRgb } from "./palette";
|
import { parseHexColor } from "./palette";
|
||||||
import type { PixelImage } from "./types";
|
import type { PixelImage } from "./types";
|
||||||
import { createPixelImage } from "./types";
|
import { createPixelImage } from "./types";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function renderPredicateMask(
|
|||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
const highlight = o.mode !== "binary";
|
const highlight = o.mode !== "binary";
|
||||||
const tint = o.color ? hexToRgb(o.color) : { r: 255, g: 0, b: 170 };
|
const tint = o.color ? parseHexColor(o.color) : { r: 255, g: 0, b: 170 };
|
||||||
const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100;
|
const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100;
|
||||||
if (!highlight) {
|
if (!highlight) {
|
||||||
// Binary mask: opaque white-on-black
|
// Binary mask: opaque white-on-black
|
||||||
@@ -95,7 +95,7 @@ export function extractByColor(
|
|||||||
tolerancePercent: number,
|
tolerancePercent: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
const t = hexToRgb(targetHex);
|
const t = parseHexColor(targetHex);
|
||||||
const tol = (Math.min(Math.max(tolerancePercent, 0), 100) / 100) * 255;
|
const tol = (Math.min(Math.max(tolerancePercent, 0), 100) / 100) * 255;
|
||||||
for (let i = 0; i < img.data.length; i += 4) {
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseHex } from "./alpha";
|
import { parseHexColor } from "./palette";
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
||||||
|
|
||||||
type Mask = Uint8Array;
|
type Mask = Uint8Array;
|
||||||
@@ -120,7 +120,7 @@ export function strokeImage(
|
|||||||
): PixelImage {
|
): PixelImage {
|
||||||
const r = Math.trunc(radiusPx);
|
const r = Math.trunc(radiusPx);
|
||||||
if (r < 1) return clonePixelImage(img);
|
if (r < 1) return clonePixelImage(img);
|
||||||
const [cr, cg, cb] = parseHex(colorHex);
|
const { r: cr, g: cg, b: cb } = parseHexColor(colorHex);
|
||||||
const mask = buildAlphaMask(img);
|
const mask = buildAlphaMask(img);
|
||||||
const ring = dilateMask(mask, img.width, img.height, r);
|
const ring = dilateMask(mask, img.width, img.height, r);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
@@ -147,7 +147,7 @@ export function contourImage(
|
|||||||
colorHex: string,
|
colorHex: string,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const r = Math.max(1, Math.trunc(radiusPx));
|
const r = Math.max(1, Math.trunc(radiusPx));
|
||||||
const [cr, cg, cb] = parseHex(colorHex);
|
const { r: cr, g: cg, b: cb } = parseHexColor(colorHex);
|
||||||
const mask = buildAlphaMask(img);
|
const mask = buildAlphaMask(img);
|
||||||
const inner = erodeMask(mask, img.width, img.height, r);
|
const inner = erodeMask(mask, img.width, img.height, r);
|
||||||
const line = new Uint8Array(mask.length);
|
const line = new Uint8Array(mask.length);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
analogousSet,
|
analogousSet,
|
||||||
complementarySet,
|
complementarySet,
|
||||||
hexToRgb,
|
parseHexColor,
|
||||||
hslToRgb,
|
hslToRgb,
|
||||||
monochromaticSet,
|
monochromaticSet,
|
||||||
mixColors,
|
mixColors,
|
||||||
@@ -19,9 +19,21 @@ import {
|
|||||||
tetradicSet,
|
tetradicSet,
|
||||||
} from "./palette";
|
} from "./palette";
|
||||||
|
|
||||||
|
describe("parseHexColor", () => {
|
||||||
|
it("parses #rrggbb, rrggbb, #rgb", () => {
|
||||||
|
expect(parseHexColor("#ff8040")).toEqual({ r: 255, g: 128, b: 64 });
|
||||||
|
expect(parseHexColor("ff8040")).toEqual({ r: 255, g: 128, b: 64 });
|
||||||
|
expect(parseHexColor("#F80")).toEqual({ r: 255, g: 136, b: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["zzz", "12345", "##ff", ""])('throws on "%s"', (bad) => {
|
||||||
|
expect(() => parseHexColor(bad)).toThrow(/errors\.badHex/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("rgb<->hsl conversion", () => {
|
describe("rgb<->hsl conversion", () => {
|
||||||
it("red -> hsl(0,100%,50%) and back", () => {
|
it("red -> hsl(0,100%,50%) and back", () => {
|
||||||
const hsl = rgbToHsl(hexToRgb("#ff0000"));
|
const hsl = rgbToHsl(parseHexColor("#ff0000"));
|
||||||
expect(hsl.h).toBeCloseTo(0, 0);
|
expect(hsl.h).toBeCloseTo(0, 0);
|
||||||
expect(hsl.s).toBeCloseTo(1, 5);
|
expect(hsl.s).toBeCloseTo(1, 5);
|
||||||
expect(hsl.l).toBeCloseTo(0.5, 5);
|
expect(hsl.l).toBeCloseTo(0.5, 5);
|
||||||
@@ -29,14 +41,14 @@ describe("rgb<->hsl conversion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("grays have no hue", () => {
|
it("grays have no hue", () => {
|
||||||
expect(rgbToHsl(hexToRgb("#808080")).s).toBe(0);
|
expect(rgbToHsl(parseHexColor("#808080")).s).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("360 deg wraparound returns original color", () => {
|
it("360 deg wraparound returns original color", () => {
|
||||||
const base = "#3b82f6";
|
const base = "#3b82f6";
|
||||||
expect(
|
expect(
|
||||||
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 })),
|
rgbToHex(hslToRgb({ ...rgbToHsl(parseHexColor(base)), h: 720 + 30 })),
|
||||||
).toBe(rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 30 })));
|
).toBe(rgbToHex(hslToRgb({ ...rgbToHsl(parseHexColor(base)), h: 30 })));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,15 +56,15 @@ describe("harmonies", () => {
|
|||||||
it("complementary - pair shifted 180 deg", () => {
|
it("complementary - pair shifted 180 deg", () => {
|
||||||
const [a, b] = complementarySet("#ff0000");
|
const [a, b] = complementarySet("#ff0000");
|
||||||
expect(a).toBe("#ff0000");
|
expect(a).toBe("#ff0000");
|
||||||
const ha = rgbToHsl(hexToRgb(a)).h;
|
const ha = rgbToHsl(parseHexColor(a)).h;
|
||||||
const hb = rgbToHsl(hexToRgb(b)).h;
|
const hb = rgbToHsl(parseHexColor(b)).h;
|
||||||
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
|
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("triadic - three colors 120 deg apart", () => {
|
it("triadic - three colors 120 deg apart", () => {
|
||||||
const set = triadicSet("#ff0000");
|
const set = triadicSet("#ff0000");
|
||||||
expect(set).toHaveLength(3);
|
expect(set).toHaveLength(3);
|
||||||
const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h);
|
const hues = set.map((h) => rgbToHsl(parseHexColor(h)).h);
|
||||||
expect(hues[1] - hues[0]).toBeCloseTo(120, 0);
|
expect(hues[1] - hues[0]).toBeCloseTo(120, 0);
|
||||||
expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
|
expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
|
||||||
});
|
});
|
||||||
@@ -71,15 +83,17 @@ describe("harmonies", () => {
|
|||||||
it("monochromatic keeps hue, varies lightness", () => {
|
it("monochromatic keeps hue, varies lightness", () => {
|
||||||
const set = monochromaticSet("#ff8800", 5, 60);
|
const set = monochromaticSet("#ff8800", 5, 60);
|
||||||
expect(set).toHaveLength(5);
|
expect(set).toHaveLength(5);
|
||||||
const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h)));
|
const hues = new Set(
|
||||||
|
set.map((h) => Math.round(rgbToHsl(parseHexColor(h)).h)),
|
||||||
|
);
|
||||||
expect(hues.size).toBe(1);
|
expect(hues.size).toBe(1);
|
||||||
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
|
const lights = set.map((h) => rgbToHsl(parseHexColor(h)).l);
|
||||||
expect(Math.min(...lights)).toBeLessThan(Math.max(...lights));
|
expect(Math.min(...lights)).toBeLessThan(Math.max(...lights));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shades - dark edge darker than base", () => {
|
it("shades - dark edge darker than base", () => {
|
||||||
const set = shadeSet("#88cc44", 4, 70);
|
const set = shadeSet("#88cc44", 4, 70);
|
||||||
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
|
const lights = set.map((h) => rgbToHsl(parseHexColor(h)).l);
|
||||||
expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
|
expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-16
@@ -6,10 +6,16 @@ import { clamp01 } from "./math";
|
|||||||
export type Rgb = { r: number; g: number; b: number };
|
export type Rgb = { r: number; g: number; b: number };
|
||||||
export type Hsl = { h: number; s: number; l: number };
|
export type Hsl = { h: number; s: number; l: number };
|
||||||
|
|
||||||
export function hexToRgb(hex: string): Rgb {
|
export function parseHexColor(hex: string): Rgb {
|
||||||
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!m) throw new ToolError("errors.badHex", { value: hex });
|
if (!m) throw new ToolError("errors.badHex", { value: hex });
|
||||||
const d = m[1];
|
const d =
|
||||||
|
m[1].length === 3
|
||||||
|
? m[1]
|
||||||
|
.split("")
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join("")
|
||||||
|
: m[1];
|
||||||
return {
|
return {
|
||||||
r: parseInt(d.slice(0, 2), 16),
|
r: parseInt(d.slice(0, 2), 16),
|
||||||
g: parseInt(d.slice(2, 4), 16),
|
g: parseInt(d.slice(2, 4), 16),
|
||||||
@@ -66,12 +72,12 @@ export function hslToRgb({ h, s, l }: Hsl): Rgb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shiftHue(hex: string, deltaDeg: number): string {
|
export function shiftHue(hex: string, deltaDeg: number): string {
|
||||||
const hsl = rgbToHsl(hexToRgb(hex));
|
const hsl = rgbToHsl(parseHexColor(hex));
|
||||||
return rgbToHex(hslToRgb({ ...hsl, h: hsl.h + deltaDeg }));
|
return rgbToHex(hslToRgb({ ...hsl, h: hsl.h + deltaDeg }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function withLightness(hex: string, l: number): string {
|
function withLightness(hex: string, l: number): string {
|
||||||
return rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(hex)), l }));
|
return rgbToHex(hslToRgb({ ...rgbToHsl(parseHexColor(hex)), l }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function complementarySet(base: string): string[] {
|
export function complementarySet(base: string): string[] {
|
||||||
@@ -109,7 +115,7 @@ export function monochromaticSet(
|
|||||||
rangePercent: number,
|
rangePercent: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const n = Math.max(2, Math.min(9, Math.round(count)));
|
const n = Math.max(2, Math.min(9, Math.round(count)));
|
||||||
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
|
const baseL = rgbToHsl(parseHexColor(normalizeHex(base))).l;
|
||||||
const halfSpan = Math.min(0.495, rangePercent / 200);
|
const halfSpan = Math.min(0.495, rangePercent / 200);
|
||||||
return Array.from({ length: n }, (_, i) => {
|
return Array.from({ length: n }, (_, i) => {
|
||||||
const t = n === 1 ? 0.5 : i / (n - 1);
|
const t = n === 1 ? 0.5 : i / (n - 1);
|
||||||
@@ -124,7 +130,7 @@ export function shadeSet(
|
|||||||
depthPercent: number,
|
depthPercent: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const n = Math.max(2, Math.min(9, Math.round(count)));
|
const n = Math.max(2, Math.min(9, Math.round(count)));
|
||||||
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
|
const baseL = rgbToHsl(parseHexColor(normalizeHex(base))).l;
|
||||||
const floorL = Math.max(0.03, baseL - depthPercent / 100);
|
const floorL = Math.max(0.03, baseL - depthPercent / 100);
|
||||||
return Array.from({ length: n }, (_, i) => {
|
return Array.from({ length: n }, (_, i) => {
|
||||||
const t = i / (n - 1);
|
const t = i / (n - 1);
|
||||||
@@ -150,7 +156,7 @@ export function normalizeHex(hex: string): string {
|
|||||||
|
|
||||||
export function mixColors(hexes: string[]): string {
|
export function mixColors(hexes: string[]): string {
|
||||||
const sum = hexes
|
const sum = hexes
|
||||||
.map(hexToRgb)
|
.map(parseHexColor)
|
||||||
.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), {
|
.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), {
|
||||||
r: 0,
|
r: 0,
|
||||||
g: 0,
|
g: 0,
|
||||||
@@ -164,7 +170,7 @@ export function mixColors(hexes: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function luma(hex: string): number {
|
export function luma(hex: string): number {
|
||||||
const { r, g, b } = hexToRgb(hex);
|
const { r, g, b } = parseHexColor(hex);
|
||||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +179,7 @@ export type SortKey = "hue" | "luma" | "sat";
|
|||||||
export function sortPalette(hexes: string[], key: SortKey): string[] {
|
export function sortPalette(hexes: string[], key: SortKey): string[] {
|
||||||
const scored = hexes.map((h) => {
|
const scored = hexes.map((h) => {
|
||||||
if (key === "luma") return { h, k: luma(h) };
|
if (key === "luma") return { h, k: luma(h) };
|
||||||
const hsl = rgbToHsl(hexToRgb(h));
|
const hsl = rgbToHsl(parseHexColor(h));
|
||||||
return { h, k: key === "hue" ? hsl.h : hsl.s };
|
return { h, k: key === "hue" ? hsl.h : hsl.s };
|
||||||
});
|
});
|
||||||
return scored
|
return scored
|
||||||
@@ -193,7 +199,7 @@ export function renderSwatches(
|
|||||||
const height = Math.max(24, Math.round(cellW));
|
const height = Math.max(24, Math.round(cellW));
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
colors.forEach((hex, i) => {
|
colors.forEach((hex, i) => {
|
||||||
const { r, g, b } = hexToRgb(hex);
|
const { r, g, b } = parseHexColor(hex);
|
||||||
fillRect(
|
fillRect(
|
||||||
out,
|
out,
|
||||||
Math.floor(i * cellW),
|
Math.floor(i * cellW),
|
||||||
@@ -213,7 +219,7 @@ export function renderSwatches(
|
|||||||
const height = cell * rows;
|
const height = cell * rows;
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
colors.forEach((hex, i) => {
|
colors.forEach((hex, i) => {
|
||||||
const { r, g, b } = hexToRgb(hex);
|
const { r, g, b } = parseHexColor(hex);
|
||||||
fillRect(
|
fillRect(
|
||||||
out,
|
out,
|
||||||
(i % cols) * cell,
|
(i % cols) * cell,
|
||||||
@@ -254,8 +260,8 @@ export function renderWheel(size: number, lightness: number): PixelImage {
|
|||||||
export function renderBlend(a: string, b: string, width: number): PixelImage {
|
export function renderBlend(a: string, b: string, width: number): PixelImage {
|
||||||
const height = Math.max(24, Math.round(width / 4));
|
const height = Math.max(24, Math.round(width / 4));
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
const ca = hexToRgb(a);
|
const ca = parseHexColor(a);
|
||||||
const cb = hexToRgb(b);
|
const cb = parseHexColor(b);
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const t = width === 1 ? 0 : x / (width - 1);
|
const t = width === 1 ? 0 : x / (width - 1);
|
||||||
fillRect(
|
fillRect(
|
||||||
@@ -278,8 +284,8 @@ export function stepColors(
|
|||||||
steps: number,
|
steps: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const n = Math.max(2, Math.min(12, Math.round(steps)));
|
const n = Math.max(2, Math.min(12, Math.round(steps)));
|
||||||
const ca = hexToRgb(aHex);
|
const ca = parseHexColor(aHex);
|
||||||
const cb = hexToRgb(bHex);
|
const cb = parseHexColor(bHex);
|
||||||
return Array.from({ length: n }, (_, i) => {
|
return Array.from({ length: n }, (_, i) => {
|
||||||
const t = i / (n - 1);
|
const t = i / (n - 1);
|
||||||
return rgbToHex({
|
return rgbToHex({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { hexToRgb } from "./palette";
|
import { parseHexColor } from "./palette";
|
||||||
import type { PixelImage } from "./types";
|
import type { PixelImage } from "./types";
|
||||||
import { createPixelImage } from "./types";
|
import { createPixelImage } from "./types";
|
||||||
import { gaussianBlur } from "./convolution";
|
import { gaussianBlur } from "./convolution";
|
||||||
@@ -189,7 +189,7 @@ export function silhouette(
|
|||||||
alphaThreshold: number,
|
alphaThreshold: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
const { r, g, b } = hexToRgb(colorHex);
|
const { r, g, b } = parseHexColor(colorHex);
|
||||||
for (let i = 0; i < img.data.length; i += 4) {
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
if (img.data[i + 3] <= alphaThreshold) continue;
|
if (img.data[i + 3] <= alphaThreshold) continue;
|
||||||
out.data[i] = r;
|
out.data[i] = r;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PixelImage } from "./types";
|
import type { PixelImage } from "./types";
|
||||||
import { createPixelImage } from "./types";
|
import { createPixelImage } from "./types";
|
||||||
import { rgbToHex } from "./palette";
|
import { rgbToHex } from "./palette";
|
||||||
import { hexToRgb } from "./palette";
|
import { parseHexColor } from "./palette";
|
||||||
|
|
||||||
type Rgb = { r: number; g: number; b: number };
|
type Rgb = { r: number; g: number; b: number };
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ export function mapToNearest(
|
|||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
paletteHexes: string[],
|
paletteHexes: string[],
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const palette = paletteHexes.map(hexToRgb);
|
const palette = paletteHexes.map(parseHexColor);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < img.data.length; i += 4) {
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
out.data[i + 3] = img.data[i + 3];
|
out.data[i + 3] = img.data[i + 3];
|
||||||
@@ -165,7 +165,7 @@ export function ditherImage(
|
|||||||
forcedPaletteHexes?: string[],
|
forcedPaletteHexes?: string[],
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const palette = forcedPaletteHexes
|
const palette = forcedPaletteHexes
|
||||||
? forcedPaletteHexes.map(hexToRgb)
|
? forcedPaletteHexes.map(parseHexColor)
|
||||||
: medianCutPalette(img, k);
|
: medianCutPalette(img, k);
|
||||||
const total = img.width * img.height;
|
const total = img.width * img.height;
|
||||||
const buf = new Float32Array(total * 3);
|
const buf = new Float32Array(total * 3);
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { changeCanvasSize } from "../../core/geometry";
|
|||||||
import {
|
import {
|
||||||
analogousSet,
|
analogousSet,
|
||||||
complementarySet,
|
complementarySet,
|
||||||
hexToRgb,
|
|
||||||
mixColors,
|
mixColors,
|
||||||
monochromaticSet,
|
monochromaticSet,
|
||||||
|
parseHexColor,
|
||||||
renderBlend,
|
renderBlend,
|
||||||
renderSwatches,
|
renderSwatches,
|
||||||
renderWheel,
|
renderWheel,
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
import { genTool, type Tool } from "../types";
|
import { genTool, type Tool } from "../types";
|
||||||
|
|
||||||
function rgba(hex: string): [number, number, number, number] {
|
function rgba(hex: string): [number, number, number, number] {
|
||||||
const { r, g, b } = hexToRgb(hex);
|
const { r, g, b } = parseHexColor(hex);
|
||||||
return [r, g, b, 255];
|
return [r, g, b, 255];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user