feat: update use dimension tools

This commit is contained in:
2026-09-05 15:11:26 +05:00
parent 2601aa5b5c
commit f286044ab9
9 changed files with 648 additions and 52 deletions
+24 -18
View File
@@ -2,18 +2,22 @@
> Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент
> в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены
> все одиночные инструменты (18 шт). Фаза 3 — **начата**: введён составной тип
> `dimension` (вложенный объект в Params + `field.dimension`, новый kind в
> `registry-schema` + мерge `DimensionField` в UI), переведён пилот
> `create-empty-png`; preview научен применять **генераторы** (`executeGenerate`,
> кнопка Generate вместо загрузки файла). Следующее: перевести остальные
> dimension-инструменты (single-color → random-noise → ...).
> все одиночные инструменты (18 шт). Фаза 3 — **составной тип `dimension`
> полностью переведён** (12 инструментов: create-empty, single-color,
> random-noise, linear-gradient, color-spectrum, random-colors, draw-grid,
> placeholder, fit-on-background, change-canvas-size, resize, crop); preview
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension).
> Следующее: `color-pair` (пилот blend-two-png → two-colors → step-colors).
>
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
> (по файлу на категорию) + `web/src/lib/preview/categories.ts`,
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
>
> - `web/src/lib/preview/categories.ts`,
> `web/src/lib/registry-schema.ts` (kind `dimension`), `kit/fields/DimensionField.svelte`,
> `preview/executor/index.ts` (`executeGenerate`). Старый `web/src/lib/registry.ts`
> разбит по категориям в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
> `preview/executor/index.ts` (`executeGenerate`), `SchemaFields.svelte`
> (полный рендер kinds). Старый `web/src/lib/registry.ts` разбит по категориям
> в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
## Ключевая стратегия: параллельная сборка, старый UI не трогаем
@@ -330,15 +334,17 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
24. `dimension` — составной тип введён ✔ (`field.dimension` + kind `dimension`
в `registry-schema`, вложенный объект `size: { width, height }` в Params,
виджет `kit/fields/DimensionField.svelte`).
Пилот `create-empty-png` переведён ✔; preview поддержал **генераторы**
(`preview/executor` + `executeGenerate`, кнопка Generate в `SchemaToolView`/
`SchemaPreview`, без входного файла).
Остальные по 1: single-color → random-noise → fit-on-background →
change-canvas-size → placeholder → draw-grid → random-colors →
color-spectrum → linear-gradient → resize → crop
24. `dimension` — **переведены все 12 инструментов** ✔ (`create-empty-png`,
`single-color-png`, `random-noise-png`, `linear-gradient-png`,
`color-spectrum-png`, `random-colors-png`, `draw-grid-png`,
`placeholder-png` — генераторы в `registry-new/generate.ts`;
`fit-on-background-png`, `change-canvas-size-png`, `resize-png`,
`crop-png` — в `registry-new/geometry.ts`). Составной тип во всех видах:
вложенный объект `size: { width, height }` в Params + `field.dimension`,
виджет `kit/fields/DimensionField.svelte`.
Preview: генераторы применяются через `executeGenerate` (кнопка Generate),
`SchemaFields` рендерит все kinds схемы (slider/number/color/select/
checkbox/dimension). Тесты: 592 passed.
25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors →
linear-gradient (уже переведён в dimension, здесь добавляется к нему)
26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask →
+46 -1
View File
@@ -51,11 +51,37 @@
/>
</span>
</label>
{:else if field.spec.kind === "select"}
<label class="control">
<span>{labelOf(id)}</span>
<select
class="select-field"
value={String(values[id] ?? field.spec.default)}
oninput={(e) => onchange(id, (e.target as HTMLSelectElement).value)}
>
{#each field.spec.options as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
{:else if field.spec.kind === "checkbox"}
<label class="control checkbox-control">
<span>{labelOf(id)}</span>
<input
type="checkbox"
class="checkbox-field"
checked={Boolean(values[id] ?? field.spec.default)}
onchange={(e) => onchange(id, (e.target as HTMLInputElement).checked)}
/>
</label>
{:else if field.spec.kind === "dimension"}
<div class="control">
<DimensionField
label={labelOf(id)}
value={(values[id] as Dimension) ?? { width: field.spec.width, height: field.spec.height }}
value={(values[id] as Dimension) ?? {
width: field.spec.width,
height: field.spec.height,
}}
spec={field.spec}
oninput={(v) => onchange(id, v)}
/>
@@ -91,6 +117,25 @@
accent-color: var(--color-main);
color: var(--color-text);
}
.select-field {
width: 100%;
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
}
.checkbox-control {
display: flex;
justify-content: space-between;
align-items: center;
}
.checkbox-field {
width: var(--space-xl);
height: var(--space-xl);
accent-color: var(--color-main);
}
.color-control > span:first-child {
justify-content: flex-start;
}
@@ -1,9 +1,9 @@
<script lang="ts">
import { Check, Sparkles, Upload } from "@lucide/svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import MetaList from "$lib/components/kit/MetaList.svelte";
import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { Check, Sparkles, Upload } from "@lucide/svelte";
interface Props {
source: PixelImage | null;
@@ -15,15 +15,25 @@
ongenerate?: () => void;
ondownload: () => void;
}
let { source, result, running, error, isGenerator = false, onupload, ongenerate, ondownload }: Props =
$props();
let {
source,
result,
running,
error,
isGenerator = false,
onupload,
ongenerate,
ondownload,
}: Props = $props();
let sourceUrl = $derived(source ? toDataUrl(source) : null);
let resultUrl = $derived(result ? toDataUrl(result) : null);
</script>
<div class="panel-head">
<span class="label">{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span>
<span class="label"
>{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span
>
<div class="head-actions">
{#if isGenerator}
<button
@@ -31,7 +41,8 @@
onclick={ongenerate}
disabled={running}
>
<Sparkles size={14} /> {running ? "Generating…" : "Generate"}
<Sparkles size={14} />
{running ? "Generating…" : "Generate"}
</button>
{:else}
<label class="upload">
@@ -75,7 +86,9 @@
{:else if running}
<Check size={22} />
{:else}
<span class="empty">{isGenerator ? "click Generate" : "no result yet"}</span>
<span class="empty">
{isGenerator ? "click Generate" : "no result yet"}
</span>
{/if}
</div>
</figure>
@@ -132,7 +145,7 @@
.generate-btn {
background: var(--color-main);
border-color: var(--color-main);
color: white;
color: var(--color-background);
}
.generate-btn:disabled {
opacity: 0.6;
@@ -42,9 +42,9 @@
}
.dimension-label {
grid-column: 1 / -1;
color: var(--foreground);
font-size: 11px;
letter-spacing: 0.5px;
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
</style>
+3 -7
View File
@@ -1,12 +1,8 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import {
ditherImage,
mapToNearest,
quantizeImage,
} from "../core/quantize";
import { gammaCorrection, temperature, tint } from "../core/color";
import { parseHexList } from "../core/palette";
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize";
import { field, toolSchema } from "../registry-schema";
import type { ToolEntry } from "./types";
interface GammaParams {
value: number;
+245 -6
View File
@@ -1,7 +1,15 @@
import type { ToolEntry } from "./types";
import { field, toolSchema, type Dimension } from "../registry-schema";
import { solidImage } from "../core/generate";
import { renderTextToImage } from "../core/domText";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
import { gradientImage, noiseImage, solidImage } from "../core/generate";
import { changeCanvasSize } from "../core/geometry";
import { hexToRgb } from "../core/palette";
import { field, toolSchema, type Dimension } from "../registry-schema";
import type { ToolEntry } from "./types";
function rgba(hex: string): [number, number, number, number] {
const { r, g, b } = hexToRgb(hex);
return [r, g, b, 255];
}
interface CreateEmptyParams {
size: Dimension;
@@ -28,9 +36,240 @@ const createEmpty: ToolEntry<CreateEmptyParams> = {
if (p.transparent) {
return solidImage(w, h, [0, 0, 0, 0]);
}
const { r, g, b } = hexToRgb(p.color);
return solidImage(w, h, [r, g, b, 255]);
return solidImage(w, h, rgba(p.color));
},
};
export const generateEntries = [createEmpty];
interface SingleColorParams {
size: Dimension;
color: string;
}
export const singleColorSchema = toolSchema<SingleColorParams>({
size: field.dimension({ min: 1, max: 20000, width: 256, height: 256 }),
color: field.color({ default: "#ff0000" }),
});
const singleColor: ToolEntry<SingleColorParams> = {
id: "single-color-png",
title: "Create solid color PNG",
description: "Generates a rectangle of the given size and color.",
category: "generate",
schema: singleColorSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return solidImage(w, h, rgba(p.color));
},
};
interface RandomNoiseParams {
size: Dimension;
seed: number;
}
export const randomNoiseSchema = toolSchema<RandomNoiseParams>({
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
seed: field.number({ min: 0, max: 999999999, step: 1, default: 1 }),
});
const randomNoise: ToolEntry<RandomNoiseParams> = {
id: "random-noise-png",
title: "Create random noise PNG",
description:
"Generates an image with random pixels. The seed fixes the result: one seed — one image.",
category: "generate",
schema: randomNoiseSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return noiseImage(w, h, p.seed);
},
};
interface LinearGradientParams {
size: Dimension;
fromColor: string;
toColor: string;
direction: "horizontal" | "vertical";
}
export const linearGradientSchema = toolSchema<LinearGradientParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
fromColor: field.color({ default: "#000000" }),
toColor: field.color({ default: "#ffffff" }),
direction: field.select({
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal" },
{ value: "vertical", label: "Vertical" },
],
}),
});
const linearGradient: ToolEntry<LinearGradientParams> = {
id: "linear-gradient-png",
title: "Create gradient PNG",
description:
"Generates a smooth transition between two colors, horizontally or vertically.",
category: "generate",
schema: linearGradientSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return gradientImage(w, h, rgba(p.fromColor), rgba(p.toColor), p.direction);
},
};
interface ColorSpectrumParams {
size: Dimension;
direction: "horizontal" | "vertical";
saturation: number;
lightness: number;
}
export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>({
size: field.dimension({ min: 1, max: 5000, width: 1024, height: 128 }),
direction: field.select({
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal" },
{ value: "vertical", label: "Vertical" },
],
}),
saturation: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
lightness: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
});
const colorSpectrumTool: ToolEntry<ColorSpectrumParams> = {
id: "color-spectrum-png",
title: "Color Spectrum PNG",
description:
"Full hue rainbow 0360° along the chosen axis with adjustable saturation and lightness.",
category: "generate",
schema: colorSpectrumSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return colorSpectrum(w, h, p.direction, p.saturation, p.lightness);
},
};
interface RandomColorsParams {
size: Dimension;
blockSize: number;
seed: number;
}
export const randomColorsSchema = toolSchema<RandomColorsParams>({
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
blockSize: field.slider({ min: 4, max: 256, step: 2, default: 64 }),
seed: field.number({ min: 0, max: 999999999, step: 1, default: 7 }),
});
const randomColors: ToolEntry<RandomColorsParams> = {
id: "random-colors-png",
title: "Random Color Blocks PNG",
description:
"Fills the canvas with random vivid color blocks. Deterministic by seed.",
category: "generate",
schema: randomColorsSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return randomColorBlocks(w, h, p.blockSize, p.seed);
},
};
interface DrawGridParams {
size: Dimension;
cols: number;
rows: number;
lineWidth: number;
color: string;
transparentBg: boolean;
}
export const drawGridSchema = toolSchema<DrawGridParams>({
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
cols: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
rows: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
lineWidth: field.slider({ min: 1, max: 40, step: 1, default: 2 }),
color: field.color({ default: "#111318" }),
transparentBg: field.checkbox({ default: true }),
});
const drawGridTool: ToolEntry<DrawGridParams> = {
id: "draw-grid-png",
title: "Draw Grid PNG",
description:
"Draws a grid with custom columns, rows and line width on a transparent or white background.",
category: "generate",
schema: drawGridSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return drawGrid(
w,
h,
p.cols,
p.rows,
p.lineWidth,
p.color,
p.transparentBg,
);
},
};
interface PlaceholderParams {
size: Dimension;
backgroundColor: string;
color: string;
showText: boolean;
}
export const placeholderSchema = toolSchema<PlaceholderParams>({
size: field.dimension({ min: 1, max: 5000, width: 800, height: 400 }),
backgroundColor: field.color({ default: "#dfe2e8" }),
color: field.color({ default: "#5c6470" }),
showText: field.checkbox({ default: true }),
});
const placeholder: ToolEntry<PlaceholderParams> = {
id: "placeholder-png",
title: "Create Placeholder PNG",
description:
"Generates a placeholder rectangle with its dimensions printed in the center.",
category: "generate",
schema: placeholderSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
let out = solidImage(w, h, rgba(p.backgroundColor));
if (p.showText) {
const label = renderTextToImage({
text: `${w} × ${h}`,
fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)),
font: "sans",
bold: true,
color: p.color,
backgroundColor: p.backgroundColor,
transparentBg: false,
padding: 0,
});
out = changeCanvasSize(label, w, h, "center");
}
return out;
},
};
export const generateEntries = [
createEmpty,
singleColor,
randomNoise,
linearGradient,
colorSpectrumTool,
randomColors,
drawGridTool,
placeholder,
];
+161 -3
View File
@@ -1,6 +1,13 @@
import { ToolError } from "../core/errors";
import {
changeCanvasSize,
crop,
expandCanvas,
resize,
type Anchor9,
} from "../core/geometry";
import { field, toolSchema, type Dimension } from "../registry-schema";
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import { expandCanvas } from "../core/geometry";
interface AddBorderParams {
thickness: number;
@@ -30,4 +37,155 @@ const addBorder: ToolEntry<AddBorderParams> = {
),
};
export const geometryEntries = [addBorder];
interface FitOnBackgroundParams {
size: Dimension;
transparent: boolean;
color: string;
}
export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
transparent: field.checkbox({ default: false }),
color: field.color({ default: "#ffffff" }),
});
const fitOnBackground: ToolEntry<FitOnBackgroundParams> = {
id: "fit-on-background-png",
title: "Fit PNG onto background",
description:
"Places the image centered on a canvas of the given size with a transparent or colored background.",
category: "geometry",
schema: fitOnBackgroundSchema,
run: (img, p) => {
const width = Math.trunc(p.size.width);
const height = Math.trunc(p.size.height);
if (width <= 0 || height <= 0) {
throw new ToolError("errors.sizePositive");
}
const left = Math.max(0, Math.floor((width - img.width) / 2));
const top = Math.max(0, Math.floor((height - img.height) / 2));
return expandCanvas(
img,
left,
top,
Math.max(0, width - img.width - left),
Math.max(0, height - img.height - top),
p.transparent ? undefined : p.color,
);
},
};
interface ChangeCanvasSizeParams {
size: Dimension;
anchor: Anchor9;
}
export const changeCanvasSizeSchema = toolSchema<ChangeCanvasSizeParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
anchor: field.select({
default: "center",
options: [
{ value: "top-left", label: "Top left" },
{ value: "top-center", label: "Top center" },
{ value: "top-right", label: "Top right" },
{ value: "middle-left", label: "Middle left" },
{ value: "center", label: "Center" },
{ value: "middle-right", label: "Middle right" },
{ value: "bottom-left", label: "Bottom left" },
{ value: "bottom-center", label: "Bottom center" },
{ value: "bottom-right", label: "Bottom right" },
],
}),
});
const changeCanvasSizeTool: ToolEntry<ChangeCanvasSizeParams> = {
id: "change-canvas-size-png",
title: "Change Canvas Size PNG",
description:
"Sets the exact canvas size: overflow is cropped, missing space is filled with transparency. Anchor picks which part of the image stays.",
category: "geometry",
schema: changeCanvasSizeSchema,
run: (img, p) =>
changeCanvasSize(
img,
Math.trunc(p.size.width),
Math.trunc(p.size.height),
p.anchor,
),
};
interface ResizeParams {
size: Dimension;
keepAspect: boolean;
}
export const resizeSchema = toolSchema<ResizeParams>({
size: field.dimension({ min: 0, max: 20000, width: 0, height: 0 }),
keepAspect: field.checkbox({ default: true }),
});
const resizeTool: ToolEntry<ResizeParams> = {
id: "resize-png",
title: "Resize PNG",
description:
"Scales the image with bilinear interpolation. With aspect kept, one side defines the scale; if both are set, the image fits inside them.",
category: "geometry",
schema: resizeSchema,
run: (img, p) => {
const keepAspect = p.keepAspect;
let w = Math.trunc(p.size.width);
let h = Math.trunc(p.size.height);
if (keepAspect) {
if (w > 0 && h > 0) {
const scale = Math.min(w / img.width, h / img.height);
w = Math.max(1, Math.round(img.width * scale));
h = Math.max(1, Math.round(img.height * scale));
} else if (w > 0) {
h = Math.max(1, Math.round((img.height / img.width) * w));
} else if (h > 0) {
w = Math.max(1, Math.round((img.width / img.height) * h));
}
}
if (w <= 0 || h <= 0) {
throw new ToolError("errors.resizeSize");
}
return resize(img, w, h);
},
};
interface CropParams {
x: number;
y: number;
size: Dimension;
}
export const cropSchema = toolSchema<CropParams>({
x: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
y: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
size: field.dimension({ min: 0, max: 100000, width: 0, height: 0 }),
});
const cropTool: ToolEntry<CropParams> = {
id: "crop-png",
title: "Crop PNG",
description:
"Cuts out a rectangular area. Coordinates and sizes may go beyond the image — the area is clipped to the intersection.",
category: "geometry",
schema: cropSchema,
run: (img, p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
if (w <= 0 || h <= 0) {
throw new ToolError("errors.cropSize");
}
return crop(img, Math.trunc(p.x), Math.trunc(p.y), w, h);
},
};
export const geometryEntries = [
addBorder,
fitOnBackground,
changeCanvasSizeTool,
resizeTool,
cropTool,
];
+137 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { TOOLS } from "../registry-new";
import { PREVIEW_GROUPS } from "../preview/catalog";
import { TOOLS } from "../registry-new";
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
describe("registry-new (переведённые инструменты)", () => {
@@ -11,14 +11,18 @@ describe("registry-new (переведённые инструменты)", () =>
it("PREVIEW_GROUPS строится без ошибок", () => {
expect(PREVIEW_GROUPS.length).toBeGreaterThan(0);
expect(PREVIEW_GROUPS.reduce((n, g) => n + g.tools.length, 0)).toBe(TOOLS.length);
expect(PREVIEW_GROUPS.reduce((n, g) => n + g.tools.length, 0)).toBe(
TOOLS.length,
);
});
it("дефолты схем дают валидные параметры (включая dimension)", () => {
for (const tool of TOOLS) {
const defaults = defaultSchemaParams(tool.schema);
const sanitized = sanitizeSchemaParams(tool.schema, defaults);
expect(Object.keys(sanitized).sort()).toEqual(Object.keys(defaults).sort());
expect(Object.keys(sanitized).sort()).toEqual(
Object.keys(defaults).sort(),
);
}
});
@@ -53,4 +57,134 @@ describe("registry-new (переведённые инструменты)", () =>
expect(img.height).toBe(200);
expect(img.data[3]).toBe(0);
});
it.each([
["single-color-png", { size: { width: 40, height: 30 }, color: "#00ff00" }],
["random-noise-png", { size: { width: 40, height: 30 }, seed: 5 }],
[
"linear-gradient-png",
{
size: { width: 40, height: 30 },
fromColor: "#000000",
toColor: "#ffffff",
direction: "horizontal",
},
],
[
"color-spectrum-png",
{
size: { width: 40, height: 30 },
direction: "horizontal",
saturation: 100,
lightness: 50,
},
],
[
"random-colors-png",
{ size: { width: 40, height: 30 }, blockSize: 16, seed: 7 },
],
[
"draw-grid-png",
{
size: { width: 40, height: 30 },
cols: 4,
rows: 3,
lineWidth: 2,
color: "#000000",
transparentBg: true,
},
],
])("генератор %s даёт картинку по dimension", async (id, params) => {
const tool = TOOLS.find((t) => t.id === id)!;
expect(tool.generate).toBeDefined();
const sanitized = sanitizeSchemaParams(tool.schema, params);
const img = await tool.generate!(sanitized);
expect(img.width).toBe(40);
expect(img.height).toBe(30);
});
it("random-noisе детерминирован по seed", async () => {
const tool = TOOLS.find((t) => t.id === "random-noise-png")!;
const a = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
size: { width: 32, height: 32 },
seed: 1,
}),
);
const b = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
size: { width: 32, height: 32 },
seed: 1,
}),
);
expect(a.data).toEqual(b.data);
});
it("resize: keepAspect с одной стороной сохраняет пропорции", async () => {
const tool = TOOLS.find((t) => t.id === "resize-png")!;
const img = solid(100, 50);
const out = await tool.run!(
img,
sanitizeSchemaParams(tool.schema, {
size: { width: 200, height: 0 },
keepAspect: true,
}),
);
expect(out.width).toBe(200);
expect(out.height).toBe(100);
});
it("crop вырезает область по x/y", async () => {
const tool = TOOLS.find((t) => t.id === "crop-png")!;
const img = solid(100, 100);
const out = await tool.run!(
img,
sanitizeSchemaParams(tool.schema, {
x: 10,
y: 20,
size: { width: 30, height: 40 },
}),
);
expect(out.width).toBe(30);
expect(out.height).toBe(40);
});
it("fit-on-background центрирует картинку на канвасе", async () => {
const tool = TOOLS.find((t) => t.id === "fit-on-background-png")!;
const img = solid(20, 10);
const out = await tool.run!(
img,
sanitizeSchemaParams(tool.schema, {
size: { width: 60, height: 30 },
transparent: true,
color: "#ffffff",
}),
);
expect(out.width).toBe(60);
expect(out.height).toBe(30);
expect(out.data[3]).toBe(0);
});
it("change-canvas-size использует anchor для позиции", async () => {
const tool = TOOLS.find((t) => t.id === "change-canvas-size-png")!;
const img = solid(10, 10);
const out = await tool.run!(
img,
sanitizeSchemaParams(tool.schema, {
size: { width: 20, height: 20 },
anchor: "top-left",
}),
);
expect(out.width).toBe(20);
expect(out.height).toBe(20);
const topLeft = out.data[3];
const center = out.data[(10 * out.width + 10) * 4 + 3];
expect(topLeft).toBe(255);
expect(center).toBe(0);
});
});
function solid(width: number, height: number) {
const data = new Uint8ClampedArray(width * height * 4).fill(255);
return { width, height, data };
}
+6 -1
View File
@@ -112,7 +112,12 @@ export const field = {
checkbox: (s: Omit<CheckboxSpec, "kind">): Field<boolean> => ({
spec: { kind: "checkbox", ...s },
}),
dimension: (s: { min: number; max: number; width: number; height: number }): Field<Dimension> => ({
dimension: (s: {
min: number;
max: number;
width: number;
height: number;
}): Field<Dimension> => ({
spec: { kind: "dimension", ...s },
}),
};