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 -8
View File
@@ -1,4 +1,4 @@
import { parseHex } from "./alpha";
import { parseHexColor } from "./palette";
import { ToolError } from "./errors";
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
import { sampleBilinear } from "./geometry";
@@ -28,7 +28,7 @@ export function transformImage(
): PixelImage {
const [a, b, c, d, e, f] = dstToSrc;
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 x = 0; x < outWidth; x++) {
const sx = a * x + c * y + e;
@@ -41,9 +41,9 @@ export function transformImage(
sy > img.height - 1 + EPS
) {
if (bg) {
out.data[di] = bg[0];
out.data[di + 1] = bg[1];
out.data[di + 2] = bg[2];
out.data[di] = bg.r;
out.data[di + 1] = bg.g;
out.data[di + 2] = bg.b;
out.data[di + 3] = 255;
}
continue;
@@ -51,9 +51,9 @@ export function transformImage(
const [r, g, bl, al] = sampleBilinear(img, sx, sy);
if (bg) {
const sa = al / 255;
out.data[di] = r * sa + bg[0] * (1 - sa);
out.data[di + 1] = g * sa + bg[1] * (1 - sa);
out.data[di + 2] = bl * sa + bg[2] * (1 - sa);
out.data[di] = r * sa + bg.r * (1 - sa);
out.data[di + 1] = g * sa + bg.g * (1 - sa);
out.data[di + 2] = bl * sa + bg.b * (1 - sa);
out.data[di + 3] = 255;
} else {
out.data[di] = r;
-13
View File
@@ -5,7 +5,6 @@ import {
flattenOntoColor,
hardenAlpha,
invertAlpha,
parseHex,
removeColorToAlpha,
roundCorners,
setAlphaChannel,
@@ -171,15 +170,3 @@ describe("flattenOntoColor", () => {
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 { ToolError } from "./errors";
import { clamp } from "./math";
import { parseHexColor } from "./palette";
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
@@ -9,7 +10,7 @@ export function removeColorToAlpha(
hex: string,
tolerancePercent = 0,
): PixelImage {
const [targetR, targetG, targetB] = parseHex(hex);
const { r: targetR, g: targetG, b: targetB } = parseHexColor(hex);
const tolerance =
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
const thresholdSq = tolerance * tolerance;
@@ -113,7 +114,7 @@ export function colorMask(
hex: string,
tolerancePercent = 0,
): PixelImage {
const [targetR, targetG, targetB] = parseHex(hex);
const { r: targetR, g: targetG, b: targetB } = parseHexColor(hex);
const tolerance =
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
const thresholdSq = tolerance * tolerance;
@@ -132,7 +133,7 @@ export function colorMask(
}
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);
for (let i = 0; i < out.data.length; i += 4) {
const a = img.data[i + 3] / 255;
@@ -144,23 +145,3 @@ export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
}
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 { clamp } from "./math";
@@ -93,7 +93,7 @@ export function backgroundRemovalMask(
img: PixelImage,
options: BackgroundOptions,
): 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);
if (options.outerOnly) {
floodFromBorders(mask, img.width, img.height);
+2 -2
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import { hexToRgb } from "./palette";
import { parseHexColor } from "./palette";
import { renderSpace, SPACES } from "./channels";
import { makeImage } from "./test-helpers";
describe("space conversions", () => {
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(s).toBeCloseTo(1);
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 { clonePixelImage, createPixelImage, type PixelImage } from "./types";
import { clampInt } from "./math";
@@ -18,7 +18,7 @@ export function expandCanvas(
const b = Math.max(0, Math.trunc(bottom));
const out = createPixelImage(img.width + l + r, img.height + t + b);
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) {
out.data[i] = cr;
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 { createPixelImage } from "./types";
@@ -22,7 +22,7 @@ export function renderPredicateMask(
): PixelImage {
const out = createPixelImage(img.width, img.height);
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;
if (!highlight) {
// Binary mask: opaque white-on-black
@@ -95,7 +95,7 @@ export function extractByColor(
tolerancePercent: number,
): PixelImage {
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;
for (let i = 0; i < img.data.length; i += 4) {
if (
+3 -3
View File
@@ -1,4 +1,4 @@
import { parseHex } from "./alpha";
import { parseHexColor } from "./palette";
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
type Mask = Uint8Array;
@@ -120,7 +120,7 @@ export function strokeImage(
): PixelImage {
const r = Math.trunc(radiusPx);
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 ring = dilateMask(mask, img.width, img.height, r);
const out = createPixelImage(img.width, img.height);
@@ -147,7 +147,7 @@ export function contourImage(
colorHex: string,
): PixelImage {
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 inner = erodeMask(mask, img.width, img.height, r);
const line = new Uint8Array(mask.length);
+25 -11
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
analogousSet,
complementarySet,
hexToRgb,
parseHexColor,
hslToRgb,
monochromaticSet,
mixColors,
@@ -19,9 +19,21 @@ import {
tetradicSet,
} 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", () => {
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.s).toBeCloseTo(1, 5);
expect(hsl.l).toBeCloseTo(0.5, 5);
@@ -29,14 +41,14 @@ describe("rgb<->hsl conversion", () => {
});
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", () => {
const base = "#3b82f6";
expect(
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 })),
).toBe(rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 30 })));
rgbToHex(hslToRgb({ ...rgbToHsl(parseHexColor(base)), h: 720 + 30 })),
).toBe(rgbToHex(hslToRgb({ ...rgbToHsl(parseHexColor(base)), h: 30 })));
});
});
@@ -44,15 +56,15 @@ describe("harmonies", () => {
it("complementary - pair shifted 180 deg", () => {
const [a, b] = complementarySet("#ff0000");
expect(a).toBe("#ff0000");
const ha = rgbToHsl(hexToRgb(a)).h;
const hb = rgbToHsl(hexToRgb(b)).h;
const ha = rgbToHsl(parseHexColor(a)).h;
const hb = rgbToHsl(parseHexColor(b)).h;
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
});
it("triadic - three colors 120 deg apart", () => {
const set = triadicSet("#ff0000");
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[2] - hues[0]).toBeCloseTo(240, 0);
});
@@ -71,15 +83,17 @@ describe("harmonies", () => {
it("monochromatic keeps hue, varies lightness", () => {
const set = monochromaticSet("#ff8800", 5, 60);
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);
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));
});
it("shades - dark edge darker than base", () => {
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]);
});
});
+22 -16
View File
@@ -6,10 +6,16 @@ import { clamp01 } from "./math";
export type Rgb = { r: number; g: number; b: number };
export type Hsl = { h: number; s: number; l: number };
export function hexToRgb(hex: string): Rgb {
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
export function parseHexColor(hex: string): Rgb {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
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 {
r: parseInt(d.slice(0, 2), 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 {
const hsl = rgbToHsl(hexToRgb(hex));
const hsl = rgbToHsl(parseHexColor(hex));
return rgbToHex(hslToRgb({ ...hsl, h: hsl.h + deltaDeg }));
}
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[] {
@@ -109,7 +115,7 @@ export function monochromaticSet(
rangePercent: number,
): string[] {
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);
return Array.from({ length: n }, (_, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
@@ -124,7 +130,7 @@ export function shadeSet(
depthPercent: number,
): string[] {
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);
return Array.from({ length: n }, (_, i) => {
const t = i / (n - 1);
@@ -150,7 +156,7 @@ export function normalizeHex(hex: string): string {
export function mixColors(hexes: string[]): string {
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 }), {
r: 0,
g: 0,
@@ -164,7 +170,7 @@ export function mixColors(hexes: string[]): string {
}
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;
}
@@ -173,7 +179,7 @@ export type SortKey = "hue" | "luma" | "sat";
export function sortPalette(hexes: string[], key: SortKey): string[] {
const scored = hexes.map((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 scored
@@ -193,7 +199,7 @@ export function renderSwatches(
const height = Math.max(24, Math.round(cellW));
const out = createPixelImage(width, height);
colors.forEach((hex, i) => {
const { r, g, b } = hexToRgb(hex);
const { r, g, b } = parseHexColor(hex);
fillRect(
out,
Math.floor(i * cellW),
@@ -213,7 +219,7 @@ export function renderSwatches(
const height = cell * rows;
const out = createPixelImage(width, height);
colors.forEach((hex, i) => {
const { r, g, b } = hexToRgb(hex);
const { r, g, b } = parseHexColor(hex);
fillRect(
out,
(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 {
const height = Math.max(24, Math.round(width / 4));
const out = createPixelImage(width, height);
const ca = hexToRgb(a);
const cb = hexToRgb(b);
const ca = parseHexColor(a);
const cb = parseHexColor(b);
for (let x = 0; x < width; x++) {
const t = width === 1 ? 0 : x / (width - 1);
fillRect(
@@ -278,8 +284,8 @@ export function stepColors(
steps: number,
): string[] {
const n = Math.max(2, Math.min(12, Math.round(steps)));
const ca = hexToRgb(aHex);
const cb = hexToRgb(bHex);
const ca = parseHexColor(aHex);
const cb = parseHexColor(bHex);
return Array.from({ length: n }, (_, i) => {
const t = i / (n - 1);
return rgbToHex({
+2 -2
View File
@@ -1,4 +1,4 @@
import { hexToRgb } from "./palette";
import { parseHexColor } from "./palette";
import type { PixelImage } from "./types";
import { createPixelImage } from "./types";
import { gaussianBlur } from "./convolution";
@@ -189,7 +189,7 @@ export function silhouette(
alphaThreshold: number,
): PixelImage {
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) {
if (img.data[i + 3] <= alphaThreshold) continue;
out.data[i] = r;
+3 -3
View File
@@ -1,7 +1,7 @@
import type { PixelImage } from "./types";
import { createPixelImage } from "./types";
import { rgbToHex } from "./palette";
import { hexToRgb } from "./palette";
import { parseHexColor } from "./palette";
type Rgb = { r: number; g: number; b: number };
@@ -127,7 +127,7 @@ export function mapToNearest(
img: PixelImage,
paletteHexes: string[],
): PixelImage {
const palette = paletteHexes.map(hexToRgb);
const palette = paletteHexes.map(parseHexColor);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < img.data.length; i += 4) {
out.data[i + 3] = img.data[i + 3];
@@ -165,7 +165,7 @@ export function ditherImage(
forcedPaletteHexes?: string[],
): PixelImage {
const palette = forcedPaletteHexes
? forcedPaletteHexes.map(hexToRgb)
? forcedPaletteHexes.map(parseHexColor)
: medianCutPalette(img, k);
const total = img.width * img.height;
const buf = new Float32Array(total * 3);
+2 -2
View File
@@ -9,9 +9,9 @@ import { changeCanvasSize } from "../../core/geometry";
import {
analogousSet,
complementarySet,
hexToRgb,
mixColors,
monochromaticSet,
parseHexColor,
renderBlend,
renderSwatches,
renderWheel,
@@ -33,7 +33,7 @@ import {
import { genTool, type Tool } from "../types";
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];
}