diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index a00e07c..383ea08 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -9,11 +9,13 @@ > тип `color-pair` полностью переведён** (4 инструмента: blend-two, > step-colors, linear-gradient, two-colors); **составной тип `offset` > полностью переведён** (4 инструмента: circle-mask, square-mask, -> star-mask, wavy-mask); preview +> star-mask, wavy-mask); **составной тип `position9` — переведены +> add-text-png и date-stamp-png** (водяной знак-картинка — отдельный шаг: +> требует overlay-механику в новом превью); preview > научен применять **генераторы** (`executeGenerate`, кнопка Generate) и > рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/ -> color-pair/offset). -> Следующее: `position9` (add-text → date-stamp → watermark-image). +> color-pair/offset/position9). +> Следующее: `font-style` (text-to-png → add-text → date-stamp). > > Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts` > (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate) @@ -362,7 +364,15 @@ Typed field builders + `Field` + `toolSchema

()` + `ToolSchema

` — `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-png` и `date-stamp-png`** ✔ (run в + `registry-new/text.ts`, оба `domOnly`). Составной тип во всех видах: + значение — строка `Position9`, kind `position9` в схеме (default/sanitize + по `POSITION9_VALUES`), виджет 3×3 `kit/fields/schema/PositionControl.svelte`. + В `ToolEntry` добавлен флаг `domOnly` (превью-executor держит такие + инструменты вне worker). `watermark-image-png` — отдельный под-шаг: + ему нужен overlay-source (`getOverlay`/store), которого в новом превью пока + нет. Тесты: +2 (602 passed). `font-style` и `plate` на этих инструментах + сводятся в шаги 28-29. 28. `font-style` — перевести text-to-png → add-text (если ещё не) → date-stamp 29. `plate` — перевести add-text → date-stamp diff --git a/web/src/lib/components/kit/SchemaFields.svelte b/web/src/lib/components/kit/SchemaFields.svelte index 0fe865d..bca96c0 100644 --- a/web/src/lib/components/kit/SchemaFields.svelte +++ b/web/src/lib/components/kit/SchemaFields.svelte @@ -14,6 +14,7 @@ import SelectControl from "./fields/schema/SelectControl.svelte"; import TextControl from "./fields/schema/TextControl.svelte"; import OffsetControl from "./fields/schema/OffsetControl.svelte"; + import PositionControl from "./fields/schema/PositionControl.svelte"; interface FieldControlProps { label: string; @@ -32,6 +33,7 @@ text: TextControl, dimension: DimensionField, offset: OffsetControl, + position9: PositionControl, }; interface Props { diff --git a/web/src/lib/components/kit/fields/schema/PositionControl.svelte b/web/src/lib/components/kit/fields/schema/PositionControl.svelte new file mode 100644 index 0000000..3485368 --- /dev/null +++ b/web/src/lib/components/kit/fields/schema/PositionControl.svelte @@ -0,0 +1,90 @@ + + +

+ {label} +
+ {#each POSITION9_VALUES as position (position)} + + {/each} +
+
+ + diff --git a/web/src/lib/registry-new/index.ts b/web/src/lib/registry-new/index.ts index a6fc4a4..08f1008 100644 --- a/web/src/lib/registry-new/index.ts +++ b/web/src/lib/registry-new/index.ts @@ -6,6 +6,7 @@ import { analyzeEntries } from "./analyze"; import { filtersEntries } from "./filters"; import { colorEntries } from "./color"; import { generateEntries } from "./generate"; +import { textEntries } from "./text"; export type { ToolEntry } from "./types"; @@ -17,6 +18,7 @@ export const TOOLS: ToolEntry[] = [ ...filtersEntries, ...colorEntries, ...generateEntries, + ...textEntries, ] as unknown as ToolEntry[]; export function getTool(id: string): ToolEntry | undefined { diff --git a/web/src/lib/registry-new/registry-new.test.ts b/web/src/lib/registry-new/registry-new.test.ts index e58d29d..356f2f8 100644 --- a/web/src/lib/registry-new/registry-new.test.ts +++ b/web/src/lib/registry-new/registry-new.test.ts @@ -304,6 +304,37 @@ describe("registry-new (переведённые инструменты)", () => }); expect(s.offset).toEqual({ x: 50, y: 0 }); }); + + it("add-text: дефолты position9 и текстовых полей", () => { + const tool = TOOLS.find((t) => t.id === "add-text-png")!; + const d = defaultSchemaParams(tool.schema); + expect(d.position).toBe("bottom-right"); + expect(d.text).toBe("Hello!"); + expect(d.plate).toBe(false); + }); + + it("sanitize чинит мусор в position9 и оставляет валидные значения", () => { + const tool = TOOLS.find((t) => t.id === "date-stamp-png")!; + const s = sanitizeSchemaParams(tool.schema, { + format: "YYYY-MM-DD", + fontSize: 32, + color: "#ffffff", + font: "mono", + bold: "no" as unknown as boolean, + position: "somewhere-outside", + margin: 20, + plate: true, + plateColor: "#000000", + plateOpacity: 55, + }); + expect(s.position).toBe("bottom-right"); + expect(s.bold).toBe(false); + const valid = sanitizeSchemaParams(tool.schema, { + ...defaultSchemaParams(tool.schema), + position: "top-center", + }); + expect(valid.position).toBe("top-center"); + }); }); function solid(width: number, height: number) { diff --git a/web/src/lib/registry-new/text.ts b/web/src/lib/registry-new/text.ts new file mode 100644 index 0000000..2de4748 --- /dev/null +++ b/web/src/lib/registry-new/text.ts @@ -0,0 +1,114 @@ +import type { ToolEntry } from "./types"; +import type { Position9 } from "../core/textdraw"; +import { drawTextBlock, type TextFont } from "../core/domText"; +import { formatStamp } from "../core/datefmt"; +import { field, toolSchema } from "../registry-schema"; + +const FONT_OPTIONS = [ + { value: "sans", label: "Sans-serif" }, + { value: "serif", label: "Serif" }, + { value: "mono", label: "Monospace" }, +] satisfies { value: TextFont; label: string }[]; + +interface AddTextParams { + text: string; + fontSize: number; + color: string; + font: TextFont; + bold: boolean; + position: Position9; + margin: number; + plate: boolean; + plateColor: string; + plateOpacity: number; +} + +const addTextSchema = toolSchema({ + text: field.text({ default: "Hello!", placeholder: "Your text" }), + fontSize: field.slider({ min: 8, max: 200, step: 1, default: 48 }), + color: field.color({ default: "#ffffff" }), + font: field.select({ default: "sans", options: FONT_OPTIONS }), + bold: field.checkbox({ default: true }), + position: field.position9({ default: "bottom-right" }), + margin: field.slider({ min: 0, max: 200, step: 1, default: 24 }), + plate: field.checkbox({ default: false }), + plateColor: field.color({ default: "#000000" }), + plateOpacity: field.slider({ min: 0, max: 100, step: 5, default: 60 }), +}); + +const addText: ToolEntry = { + id: "add-text-png", + title: "Add text to PNG", + description: + "Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.", + category: "text", + domOnly: true, + schema: addTextSchema, + run: (img, p) => + drawTextBlock(img, { + text: p.text, + fontSize: p.fontSize, + font: p.font, + bold: p.bold, + color: p.color, + opacityPercent: 100, + position: p.position, + margin: p.margin, + plateColor: p.plate ? p.plateColor : undefined, + plateOpacityPercent: p.plateOpacity, + }), +}; + +interface DateStampParams { + format: string; + fontSize: number; + color: string; + font: TextFont; + bold: boolean; + position: Position9; + margin: number; + plate: boolean; + plateColor: string; + plateOpacity: number; +} + +const dateStampSchema = toolSchema({ + format: field.text({ + default: "YYYY-MM-DD", + placeholder: "YYYY-MM-DD hh:mm", + }), + fontSize: field.slider({ min: 8, max: 200, step: 1, default: 32 }), + color: field.color({ default: "#ffffff" }), + font: field.select({ default: "mono", options: FONT_OPTIONS }), + bold: field.checkbox({ default: false }), + position: field.position9({ default: "bottom-right" }), + margin: field.slider({ min: 0, max: 200, step: 1, default: 20 }), + plate: field.checkbox({ default: true }), + plateColor: field.color({ default: "#000000" }), + plateOpacity: field.slider({ min: 0, max: 100, step: 5, default: 55 }), +}); + +const dateStamp: ToolEntry = { + id: "date-stamp-png", + title: "Date stamp PNG", + description: + "Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.", + category: "text", + domOnly: true, + schema: dateStampSchema, + run: (img, p) => + drawTextBlock(img, { + text: formatStamp(new Date(), p.format), + fontSize: p.fontSize, + font: p.font, + bold: p.bold, + color: p.color, + opacityPercent: 100, + position: p.position, + margin: p.margin, + plateColor: p.plate ? p.plateColor : undefined, + plateOpacityPercent: p.plateOpacity, + }), +}; + +export const textEntries = [addText, dateStamp]; diff --git a/web/src/lib/registry-new/types.ts b/web/src/lib/registry-new/types.ts index c69dd3b..bfca23c 100644 --- a/web/src/lib/registry-new/types.ts +++ b/web/src/lib/registry-new/types.ts @@ -13,6 +13,8 @@ export type ToolEntry

> = { description: string; category: CategoryId; schema: ToolSchema

; + /** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */ + domOnly?: boolean; /** Применение к входному изображению. Для чистых генераторов отсутствует. */ run?(img: PixelImage, params: P): Promise | PixelImage; generate?(params: P): Promise | PixelImage; diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index b012749..62a8c91 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -11,6 +11,8 @@ // с полями `P`, а `Field` тип-проверялся на соответствие `P[K]`. // Ошибки компилятора ловят расхождения между интерфейсом Params и схемой. +import type { Position9 } from "./core/textdraw"; + export interface NumberSpec { kind: "number"; default: number; @@ -91,6 +93,27 @@ export interface OffsetSpec { y: number; } +/** + * 9-позиционная сетка (3×3): top/middle/bottom × left/center/right. + * Значение совпадает со строковым типом `Position9` из core/textdraw. + */ +export const POSITION9_VALUES = [ + "top-left", + "top-center", + "top-right", + "middle-left", + "center", + "middle-right", + "bottom-left", + "bottom-center", + "bottom-right", +] as const; + +export interface Position9Spec { + kind: "position9"; + default: Position9; +} + /** * «Объект as const» kind → спека поля. Единственный источник правды для * перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому @@ -107,6 +130,7 @@ export const fieldSpecs = { dimension: {} as DimensionSpec, "color-pair": {} as ColorPairSpec, offset: {} as OffsetSpec, + position9: {} as Position9Spec, } as const; export type FieldSpecKind = keyof typeof fieldSpecs; @@ -171,6 +195,9 @@ export const field = { }): Field => ({ spec: { kind: "offset", ...s }, }), + position9: (s: { default: Position9 }): Field => ({ + spec: { kind: "position9", ...s }, + }), }; /** @@ -286,6 +313,13 @@ export function sanitizeSchemaParams

( out[key] = { from, to }; break; } + case "position9": + out[key] = + typeof raw === "string" && + (POSITION9_VALUES as readonly string[]).includes(raw) + ? (raw as Position9) + : spec.default; + break; case "offset": { const r = typeof raw === "object" && raw !== null && "x" in raw && "y" in raw