refactor: parse hex color

This commit is contained in:
2026-10-10 07:07:43 +05:00
parent 0b929eced2
commit 637f656229
14 changed files with 86 additions and 95 deletions
+8 -5
View File
@@ -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)
+8 -8
View File
@@ -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;
-13
View File
@@ -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/);
});
});
+4 -23
View File
@@ -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),
];
}
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -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 (
+3 -3
View File
@@ -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);
+25 -11
View File
@@ -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
View File
@@ -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({
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -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);
+2 -2
View File
@@ -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];
} }