mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: update use dimension tools
This commit is contained in:
@@ -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`,
|
||||
> `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` — тонкий баррель).
|
||||
> (по файлу на категорию: 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`), `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 →
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 0–360° 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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user