mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add offset field component
This commit is contained in:
@@ -7,12 +7,13 @@
|
|||||||
> random-noise, linear-gradient, color-spectrum, random-colors, draw-grid,
|
> random-noise, linear-gradient, color-spectrum, random-colors, draw-grid,
|
||||||
> placeholder, fit-on-background, change-canvas-size, resize, crop); **составной
|
> placeholder, fit-on-background, change-canvas-size, resize, crop); **составной
|
||||||
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
|
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
|
||||||
> step-colors, linear-gradient, two-colors); preview
|
> step-colors, linear-gradient, two-colors); **составной тип `offset`
|
||||||
|
> полностью переведён** (4 инструмента: circle-mask, square-mask,
|
||||||
|
> star-mask, wavy-mask); preview
|
||||||
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
||||||
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
||||||
> color-pair).
|
> color-pair/offset).
|
||||||
> Следующее: `offset` (пилот circle-mask-png → square-mask → star-mask →
|
> Следующее: `position9` (add-text → date-stamp → watermark-image).
|
||||||
> wavy-mask).
|
|
||||||
>
|
>
|
||||||
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||||
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
||||||
@@ -355,8 +356,12 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
|||||||
Составной тип во всех видах: вложенный объект `pair: { from, to }` +
|
Составной тип во всех видах: вложенный объект `pair: { from, to }` +
|
||||||
`field.colorPair`, виджет `kit/fields/schema/ColorPairControl.svelte`,
|
`field.colorPair`, виджет `kit/fields/schema/ColorPairControl.svelte`,
|
||||||
kind `color-pair` в схеме (default/sanitize). Тесты: +4 (596 passed).
|
kind `color-pair` в схеме (default/sanitize). Тесты: +4 (596 passed).
|
||||||
26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask →
|
26. `offset` — **все 4 инструмента переведены** ✔ (`circle-mask-png`,
|
||||||
wavy-mask
|
`square-mask-png`, `star-mask-png`, `wavy-mask-png` — run в
|
||||||
|
`registry-new/alpha.ts`). Составной тип во всех видах: вложенный объект
|
||||||
|
`offset: { x, y }` + `field.offset`, виджет
|
||||||
|
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
|
||||||
|
(default/sanitize). Тесты: +4 (600 passed).
|
||||||
27. `position9` — перевести add-text → date-stamp → watermark-image
|
27. `position9` — перевести add-text → date-stamp → watermark-image
|
||||||
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
|
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
|
||||||
date-stamp
|
date-stamp
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import RangeControl from "./fields/schema/RangeControl.svelte";
|
import RangeControl from "./fields/schema/RangeControl.svelte";
|
||||||
import SelectControl from "./fields/schema/SelectControl.svelte";
|
import SelectControl from "./fields/schema/SelectControl.svelte";
|
||||||
import TextControl from "./fields/schema/TextControl.svelte";
|
import TextControl from "./fields/schema/TextControl.svelte";
|
||||||
|
import OffsetControl from "./fields/schema/OffsetControl.svelte";
|
||||||
|
|
||||||
interface FieldControlProps {
|
interface FieldControlProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
checkbox: CheckboxControl,
|
checkbox: CheckboxControl,
|
||||||
text: TextControl,
|
text: TextControl,
|
||||||
dimension: DimensionField,
|
dimension: DimensionField,
|
||||||
|
offset: OffsetControl,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FieldSpec, Offset, OffsetSpec } from "$lib/registry-schema";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: unknown;
|
||||||
|
spec: FieldSpec;
|
||||||
|
onchange?: (value: Offset) => void;
|
||||||
|
}
|
||||||
|
let { label, value, spec, onchange }: Props = $props();
|
||||||
|
|
||||||
|
const sp = $derived(spec as OffsetSpec);
|
||||||
|
const current = $derived.by(() => {
|
||||||
|
const v = value as Partial<Offset> | undefined;
|
||||||
|
return {
|
||||||
|
x: typeof v?.x === "number" && Number.isFinite(v.x) ? v.x : sp.x,
|
||||||
|
y: typeof v?.y === "number" && Number.isFinite(v.y) ? v.y : sp.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function setAxis(axis: "x" | "y", n: number) {
|
||||||
|
onchange?.({ ...current, [axis]: n });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="control offset-control">
|
||||||
|
<span class="offset-label">{label}</span>
|
||||||
|
<div class="offset-field">
|
||||||
|
<label class="offset-axis">
|
||||||
|
<span>X</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={sp.min}
|
||||||
|
max={sp.max}
|
||||||
|
value={current.x}
|
||||||
|
oninput={(e) =>
|
||||||
|
setAxis("x", Number((e.target as HTMLInputElement).value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="offset-axis">
|
||||||
|
<span>Y</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={sp.min}
|
||||||
|
max={sp.max}
|
||||||
|
value={current.y}
|
||||||
|
oninput={(e) =>
|
||||||
|
setAxis("y", Number((e.target as HTMLInputElement).value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.control {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-m);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
letter-spacing: var(--space-text-m);
|
||||||
|
}
|
||||||
|
.offset-label {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
letter-spacing: var(--space-text-l);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.offset-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
.offset-axis > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-m);
|
||||||
|
}
|
||||||
|
.offset-axis input {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { ToolEntry } from "./types";
|
import type { ToolEntry } from "./types";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
import { field, toolSchema } from "../registry-schema";
|
||||||
|
import type { Offset } from "../registry-schema";
|
||||||
import { colorMask, removeColorToAlpha } from "../core/alpha";
|
import { colorMask, removeColorToAlpha } from "../core/alpha";
|
||||||
import { contourImage, strokeImage } from "../core/morphology";
|
import { contourImage, strokeImage } from "../core/morphology";
|
||||||
|
import {
|
||||||
|
boxTest,
|
||||||
|
circleTest,
|
||||||
|
renderShape,
|
||||||
|
starTest,
|
||||||
|
wavyTest,
|
||||||
|
} from "../core/shapes";
|
||||||
|
|
||||||
interface AddStrokeParams {
|
interface AddStrokeParams {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -64,4 +72,130 @@ const removeColor: ToolEntry<RemoveColorParams> = {
|
|||||||
preview: (img, p) => colorMask(img, p.targetColor, p.tolerance),
|
preview: (img, p) => colorMask(img, p.targetColor, p.tolerance),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const alphaEntries = [addStroke, findContour, removeColor];
|
interface CircleMaskParams {
|
||||||
|
size: number;
|
||||||
|
offset: Offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const circleMaskSchema = toolSchema<CircleMaskParams>({
|
||||||
|
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
|
||||||
|
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const circleMask: ToolEntry<CircleMaskParams> = {
|
||||||
|
id: "circle-mask-png",
|
||||||
|
title: "Circle Mask PNG",
|
||||||
|
description:
|
||||||
|
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: circleMaskSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
renderShape(
|
||||||
|
img,
|
||||||
|
circleTest(p.size / 200),
|
||||||
|
p.offset.x / 100,
|
||||||
|
p.offset.y / 100,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SquareMaskParams {
|
||||||
|
widthPct: number;
|
||||||
|
heightPct: number;
|
||||||
|
offset: Offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const squareMaskSchema = toolSchema<SquareMaskParams>({
|
||||||
|
widthPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
|
||||||
|
heightPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
|
||||||
|
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const squareMask: ToolEntry<SquareMaskParams> = {
|
||||||
|
id: "square-mask-png",
|
||||||
|
title: "Square Mask PNG",
|
||||||
|
description:
|
||||||
|
"Cuts the image into a rectangle with sides as a share of the smaller side.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: squareMaskSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
renderShape(
|
||||||
|
img,
|
||||||
|
boxTest(p.widthPct / 200, p.heightPct / 200),
|
||||||
|
p.offset.x / 100,
|
||||||
|
p.offset.y / 100,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StarMaskParams {
|
||||||
|
points: number;
|
||||||
|
innerRadius: number;
|
||||||
|
size: number;
|
||||||
|
rotation: number;
|
||||||
|
offset: Offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const starMaskSchema = toolSchema<StarMaskParams>({
|
||||||
|
points: field.slider({ min: 3, max: 12, step: 1, default: 5 }),
|
||||||
|
innerRadius: field.slider({ min: 10, max: 90, step: 1, default: 45 }),
|
||||||
|
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
|
||||||
|
rotation: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
|
||||||
|
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const starMask: ToolEntry<StarMaskParams> = {
|
||||||
|
id: "star-mask-png",
|
||||||
|
title: "Star Mask PNG",
|
||||||
|
description:
|
||||||
|
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: starMaskSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
renderShape(
|
||||||
|
img,
|
||||||
|
starTest(p.points, p.innerRadius / 100, p.size / 200, p.rotation),
|
||||||
|
p.offset.x / 100,
|
||||||
|
p.offset.y / 100,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WavyMaskParams {
|
||||||
|
size: number;
|
||||||
|
amplitude: number;
|
||||||
|
waves: number;
|
||||||
|
phase: number;
|
||||||
|
offset: Offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wavyMaskSchema = toolSchema<WavyMaskParams>({
|
||||||
|
size: field.slider({ min: 20, max: 100, step: 1, default: 90 }),
|
||||||
|
amplitude: field.slider({ min: 2, max: 30, step: 1, default: 8 }),
|
||||||
|
waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }),
|
||||||
|
phase: field.slider({ min: 0, max: 360, step: 1, default: 0 }),
|
||||||
|
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const wavyMask: ToolEntry<WavyMaskParams> = {
|
||||||
|
id: "wavy-mask-png",
|
||||||
|
title: "Wavy Mask PNG",
|
||||||
|
description:
|
||||||
|
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: wavyMaskSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
renderShape(
|
||||||
|
img,
|
||||||
|
wavyTest(p.size / 200, p.amplitude / 200, p.waves, p.phase),
|
||||||
|
p.offset.x / 100,
|
||||||
|
p.offset.y / 100,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const alphaEntries = [
|
||||||
|
addStroke,
|
||||||
|
findContour,
|
||||||
|
removeColor,
|
||||||
|
circleMask,
|
||||||
|
squareMask,
|
||||||
|
starMask,
|
||||||
|
wavyMask,
|
||||||
|
];
|
||||||
|
|||||||
@@ -244,6 +244,66 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
expect(s.pair).toEqual({ from: "#ffffff", to: "#00ff00" });
|
expect(s.pair).toEqual({ from: "#ffffff", to: "#00ff00" });
|
||||||
expect(s.threshold).toBe(100);
|
expect(s.threshold).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("circle-mask: срезает углы и сохраняет центр", async () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "circle-mask-png")!;
|
||||||
|
const img = await tool.run!(
|
||||||
|
solid(100, 100),
|
||||||
|
sanitizeSchemaParams(tool.schema, {
|
||||||
|
size: 100,
|
||||||
|
offset: { x: 0, y: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Внешний угол (0,0) — вне круга радиуса 50 → прозрачен
|
||||||
|
expect(img.data[3]).toBe(0);
|
||||||
|
// Центр (50,50) — внутри
|
||||||
|
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("circle-mask: offset сдвигает фигуру", async () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "circle-mask-png")!;
|
||||||
|
const img = await tool.run!(
|
||||||
|
solid(100, 100),
|
||||||
|
sanitizeSchemaParams(tool.schema, {
|
||||||
|
size: 50,
|
||||||
|
offset: { x: 50, y: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Радиус 25, центр смещён к x=100; пиксель (75,50) внутри, (49,50) снаружи
|
||||||
|
expect(img.data[(50 * img.width + 75) * 4 + 3]).toBe(255);
|
||||||
|
expect(img.data[(50 * img.width + 49) * 4 + 3]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("star-mask: сохраняет центр, режет углы", async () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "star-mask-png")!;
|
||||||
|
const img = await tool.run!(
|
||||||
|
solid(100, 100),
|
||||||
|
sanitizeSchemaParams(tool.schema, {
|
||||||
|
points: 5,
|
||||||
|
innerRadius: 45,
|
||||||
|
size: 100,
|
||||||
|
rotation: 0,
|
||||||
|
offset: { x: 0, y: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
|
||||||
|
expect(img.data[3]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitize клампит offset к min/max и чинит мусор", () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "wavy-mask-png")!;
|
||||||
|
const s = sanitizeSchemaParams(tool.schema, {
|
||||||
|
size: 90,
|
||||||
|
amplitude: 8,
|
||||||
|
waves: 8,
|
||||||
|
phase: 0,
|
||||||
|
offset: { x: 999, y: "abc", extra: true } as unknown as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
});
|
||||||
|
expect(s.offset).toEqual({ x: 50, y: 0 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function solid(width: number, height: number) {
|
function solid(width: number, height: number) {
|
||||||
|
|||||||
@@ -76,6 +76,21 @@ export interface ColorPairSpec {
|
|||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Смещение фигуры/объекта по центру: x + y в процентах. */
|
||||||
|
export interface Offset {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OffsetSpec {
|
||||||
|
kind: "offset";
|
||||||
|
/** Общий диапазон для обеих осей (в процентах). */
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* «Объект as const» kind → спека поля. Единственный источник правды для
|
* «Объект as const» kind → спека поля. Единственный источник правды для
|
||||||
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
|
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
|
||||||
@@ -91,6 +106,7 @@ export const fieldSpecs = {
|
|||||||
checkbox: {} as CheckboxSpec,
|
checkbox: {} as CheckboxSpec,
|
||||||
dimension: {} as DimensionSpec,
|
dimension: {} as DimensionSpec,
|
||||||
"color-pair": {} as ColorPairSpec,
|
"color-pair": {} as ColorPairSpec,
|
||||||
|
offset: {} as OffsetSpec,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FieldSpecKind = keyof typeof fieldSpecs;
|
export type FieldSpecKind = keyof typeof fieldSpecs;
|
||||||
@@ -147,6 +163,14 @@ export const field = {
|
|||||||
colorPair: (s: { from: string; to: string }): Field<ColorPair> => ({
|
colorPair: (s: { from: string; to: string }): Field<ColorPair> => ({
|
||||||
spec: { kind: "color-pair", ...s },
|
spec: { kind: "color-pair", ...s },
|
||||||
}),
|
}),
|
||||||
|
offset: (s: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}): Field<Offset> => ({
|
||||||
|
spec: { kind: "offset", ...s },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,6 +201,8 @@ export function defaultSchemaParams<P>(
|
|||||||
out[key] = { width: spec.width, height: spec.height };
|
out[key] = { width: spec.width, height: spec.height };
|
||||||
} else if (spec.kind === "color-pair") {
|
} else if (spec.kind === "color-pair") {
|
||||||
out[key] = { from: spec.from, to: spec.to };
|
out[key] = { from: spec.from, to: spec.to };
|
||||||
|
} else if (spec.kind === "offset") {
|
||||||
|
out[key] = { x: spec.x, y: spec.y };
|
||||||
} else {
|
} else {
|
||||||
out[key] = spec.default;
|
out[key] = spec.default;
|
||||||
}
|
}
|
||||||
@@ -260,6 +286,21 @@ export function sanitizeSchemaParams<P>(
|
|||||||
out[key] = { from, to };
|
out[key] = { from, to };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "offset": {
|
||||||
|
const r =
|
||||||
|
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
|
||||||
|
? (raw as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const x =
|
||||||
|
typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x;
|
||||||
|
const y =
|
||||||
|
typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y;
|
||||||
|
out[key] = {
|
||||||
|
x: clamp(x, spec.min, spec.max),
|
||||||
|
y: clamp(y, spec.min, spec.max),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
Reference in New Issue
Block a user