diff --git a/docs/architecture-audit-2026-10.md b/docs/architecture-audit-2026-10.md index fd4c254..84bc009 100644 --- a/docs/architecture-audit-2026-10.md +++ b/docs/architecture-audit-2026-10.md @@ -110,12 +110,15 @@ round-семантика `clampByte` и trunc-семантика `clampInt` со переведён на общий `clamp` через `?? -Infinity / ?? Infinity`. Копия в `palette.test.ts` оставлена — тесты независимы. -### M8. Два параллельных hex-парсера +### M8. Два параллельных hex-парсера — СДЕЛАНО (2026-10) -`parseHex` (`core/alpha.ts:147`, tuple, бросает `ToolError`) и `hexToRgb` -(`core/palette.ts:8`, объект `{r,g,b}`). Половина core-модулей использует первый -(`affine.ts:31`, `geometry.ts:20`, `morphology.ts:123,150`), другая — второй -(`masks.ts:25,98`, `pixel-fx.ts:195`, `registry/tools/generate.ts:36`). +Единый парсер — `parseHexColor(hex): Rgb` в `core/palette.ts` (терпимый: `#?`, +3/6 цифр, бросает `ToolError`). `hexToRgb` удалён; `parseHex` из `core/alpha.ts` +удалён, потребители (`affine`, `background`, `geometry`, `morphology`, `alpha`) +переведены на объектную деструктуризацию и импорт из `palette`. Тест `parseHex` +переехал из `alpha.test.ts` в `palette.test.ts`. Поведенческое послабление: +бывшие потребители `hexToRgb` теперь принимают и 3-значный hex — вход всё равно +проходит санитизацию схемы. ### M9. Гард `localStorage` продублирован 4 раза — СДЕЛАНО (2026-10) diff --git a/web/src/lib/core/affine.ts b/web/src/lib/core/affine.ts index e81ddd6..631aa6e 100644 --- a/web/src/lib/core/affine.ts +++ b/web/src/lib/core/affine.ts @@ -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; diff --git a/web/src/lib/core/alpha.test.ts b/web/src/lib/core/alpha.test.ts index 9960228..2156919 100644 --- a/web/src/lib/core/alpha.test.ts +++ b/web/src/lib/core/alpha.test.ts @@ -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/); - }); -}); diff --git a/web/src/lib/core/alpha.ts b/web/src/lib/core/alpha.ts index fe90798..ae8dd7b 100644 --- a/web/src/lib/core/alpha.ts +++ b/web/src/lib/core/alpha.ts @@ -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), - ]; -} diff --git a/web/src/lib/core/background.ts b/web/src/lib/core/background.ts index ca1c2d5..2de35a4 100644 --- a/web/src/lib/core/background.ts +++ b/web/src/lib/core/background.ts @@ -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); diff --git a/web/src/lib/core/channels.test.ts b/web/src/lib/core/channels.test.ts index e7465f6..c670c51 100644 --- a/web/src/lib/core/channels.test.ts +++ b/web/src/lib/core/channels.test.ts @@ -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); diff --git a/web/src/lib/core/geometry.ts b/web/src/lib/core/geometry.ts index 68952a9..98937f8 100644 --- a/web/src/lib/core/geometry.ts +++ b/web/src/lib/core/geometry.ts @@ -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; diff --git a/web/src/lib/core/masks.ts b/web/src/lib/core/masks.ts index 89bfb9b..0963b70 100644 --- a/web/src/lib/core/masks.ts +++ b/web/src/lib/core/masks.ts @@ -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 ( diff --git a/web/src/lib/core/morphology.ts b/web/src/lib/core/morphology.ts index 26ab387..6bafa9e 100644 --- a/web/src/lib/core/morphology.ts +++ b/web/src/lib/core/morphology.ts @@ -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); diff --git a/web/src/lib/core/palette.test.ts b/web/src/lib/core/palette.test.ts index 3f9df30..c24e7e6 100644 --- a/web/src/lib/core/palette.test.ts +++ b/web/src/lib/core/palette.test.ts @@ -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]); }); }); diff --git a/web/src/lib/core/palette.ts b/web/src/lib/core/palette.ts index f495832..7cf4dff 100644 --- a/web/src/lib/core/palette.ts +++ b/web/src/lib/core/palette.ts @@ -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({ diff --git a/web/src/lib/core/pixel-fx.ts b/web/src/lib/core/pixel-fx.ts index 06cabfe..9dd77bc 100644 --- a/web/src/lib/core/pixel-fx.ts +++ b/web/src/lib/core/pixel-fx.ts @@ -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; diff --git a/web/src/lib/core/quantize.ts b/web/src/lib/core/quantize.ts index 1bdd90e..6f803f3 100644 --- a/web/src/lib/core/quantize.ts +++ b/web/src/lib/core/quantize.ts @@ -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); diff --git a/web/src/lib/registry/tools/generate.ts b/web/src/lib/registry/tools/generate.ts index f24941e..e9ef8c4 100644 --- a/web/src/lib/registry/tools/generate.ts +++ b/web/src/lib/registry/tools/generate.ts @@ -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]; }