diff --git a/web/src/lib/components/ParamForm.svelte b/web/src/lib/components/ParamForm.svelte
index 9a3bedc..1ad340d 100644
--- a/web/src/lib/components/ParamForm.svelte
+++ b/web/src/lib/components/ParamForm.svelte
@@ -75,6 +75,14 @@
pipetteActive={pipetteTargetId === param.id}
onPipetteToggle={() => onPipetteToggle?.(param.id)}
/>
+ {:else if param.type === 'text'}
+
{/if}
{/each}
diff --git a/web/src/lib/components/ui/TextField.svelte b/web/src/lib/components/ui/TextField.svelte
index 2a290de..809ff24 100644
--- a/web/src/lib/components/ui/TextField.svelte
+++ b/web/src/lib/components/ui/TextField.svelte
@@ -10,11 +10,20 @@
max?: number;
step?: number;
hint?: string;
+ placeholder?: string;
}
- let { id, label, value = $bindable(''), type = 'text', min, max, step, hint }: Props = $props();
+ let { id, label, value = $bindable(''), type = 'text', min, max, step, hint, placeholder }: Props =
+ $props();
-
+
+
+
diff --git a/web/src/lib/core/datefmt.test.ts b/web/src/lib/core/datefmt.test.ts
new file mode 100644
index 0000000..6963b08
--- /dev/null
+++ b/web/src/lib/core/datefmt.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+import { formatStamp } from './datefmt';
+
+const d = new Date(2026, 7, 25, 9, 5, 3);
+
+describe('formatStamp', () => {
+ it('разворачивает все базовые токены', () => {
+ expect(formatStamp(d, 'YYYY-MM-DD hh:mm:ss')).toBe('2026-08-25 09:05:03');
+ });
+
+ it('произвольный текст между токенами сохраняется', () => {
+ expect(formatStamp(d, 'DD.MM.YYYY')).toBe('25.08.2026');
+ expect(formatStamp(d, 'YYYY год, MM месяц')).toBe('2026 год, 08 месяц');
+ });
+
+ it('неизвестные последовательности не трогаются', () => {
+ expect(formatStamp(d, 'YYYYYY MMМ')).toBe('2026YY 08М');
+ });
+});
diff --git a/web/src/lib/core/datefmt.ts b/web/src/lib/core/datefmt.ts
new file mode 100644
index 0000000..832d630
--- /dev/null
+++ b/web/src/lib/core/datefmt.ts
@@ -0,0 +1,26 @@
+const PAD2 = (n: number) => String(n).padStart(2, '0');
+
+/**
+ * Мини-форматтер штампа даты: токены YYYY MM DD hh mm ss заменяются
+ * значениями локального времени, остальные символы остаются как есть.
+ */
+export function formatStamp(date: Date, pattern: string): string {
+ return pattern.replace(/YYYY|MM|DD|hh|mm|ss/g, (token) => {
+ switch (token) {
+ case 'YYYY':
+ return String(date.getFullYear());
+ case 'MM':
+ return PAD2(date.getMonth() + 1);
+ case 'DD':
+ return PAD2(date.getDate());
+ case 'hh':
+ return PAD2(date.getHours());
+ case 'mm':
+ return PAD2(date.getMinutes());
+ case 'ss':
+ return PAD2(date.getSeconds());
+ default:
+ return token;
+ }
+ });
+}
diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts
index a91046b..68945d1 100644
--- a/web/src/lib/i18n/en.ts
+++ b/web/src/lib/i18n/en.ts
@@ -138,6 +138,7 @@ export const en: Dict = {
noSteps: 'The file has no list of steps',
paramNumber: 'Parameter "{id}" must be a number',
paramString: 'Parameter "{id}" must be a string',
+ paramBool: 'Parameter "{id}" must be a checkbox value',
resizeSize: 'Width and/or height must be positive',
cropSize: 'Crop width and height must be positive',
sizePositive: 'Dimensions must be positive and finite'
diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts
index 37b6a21..012f499 100644
--- a/web/src/lib/i18n/ru.ts
+++ b/web/src/lib/i18n/ru.ts
@@ -138,6 +138,7 @@ export const ru: Dict = {
noSteps: 'В файле нет списка шагов',
paramNumber: 'Параметр "{id}" должен быть числом',
paramString: 'Параметр "{id}" должен быть строкой',
+ paramBool: 'Параметр "{id}" должен быть значением флажка',
resizeSize: 'Ширина и/или высота должны быть положительными',
cropSize: 'Ширина и высота области обрезки должны быть положительными',
sizePositive: 'Размеры должны быть положительными и конечными'
@@ -439,6 +440,68 @@ export const ru: Dict = {
grayscaleNo: 'Нет — найдены цветные пиксели.'
}
},
+ 'add-text-png': {
+ title: 'Надпись на PNG',
+ description:
+ 'Рисует текст на изображении: шрифт, размер, цвет, жирность, позиция на сетке 3×3 и опциональная подложка.',
+ params: {
+ text: 'Текст',
+ fontSize: 'Размер шрифта, px',
+ color: 'Цвет текста',
+ font: 'Шрифт',
+ bold: 'Жирный',
+ position: 'Позиция',
+ margin: 'Отступ, px',
+ plate: 'Подложка',
+ plateColor: 'Цвет подложки',
+ plateOpacity: 'Прозрачность плашки, %'
+ },
+ options: {
+ font: { sans: 'Без засечек', serif: 'С засечками', mono: 'Моноширинный' },
+ position: {
+ 'top-left': 'Сверху слева',
+ 'top-center': 'Сверху по центру',
+ 'top-right': 'Сверху справа',
+ 'middle-left': 'По центру слева',
+ center: 'По центру',
+ 'middle-right': 'По центру справа',
+ 'bottom-left': 'Снизу слева',
+ 'bottom-center': 'Снизу по центру',
+ 'bottom-right': 'Снизу справа'
+ }
+ }
+ },
+ 'date-stamp-png': {
+ title: 'Дата-штамп PNG',
+ description:
+ 'Ставит текущую дату и время по строке формата (токены YYYY MM DD hh mm ss). Оформление — как у надписи.',
+ params: {
+ format: 'Формат',
+ fontSize: 'Размер шрифта, px',
+ color: 'Цвет текста',
+ font: 'Шрифт',
+ bold: 'Жирный',
+ position: 'Позиция',
+ margin: 'Отступ, px',
+ plate: 'Подложка',
+ plateColor: 'Цвет подложки',
+ plateOpacity: 'Прозрачность плашки, %'
+ },
+ options: {
+ font: { sans: 'Без засечек', serif: 'С засечками', mono: 'Моноширинный' },
+ position: {
+ 'top-left': 'Сверху слева',
+ 'top-center': 'Сверху по центру',
+ 'top-right': 'Сверху справа',
+ 'middle-left': 'По центру слева',
+ center: 'По центру',
+ 'middle-right': 'По центру справа',
+ 'bottom-left': 'Снизу слева',
+ 'bottom-center': 'Снизу по центру',
+ 'bottom-right': 'Снизу справа'
+ }
+ }
+ },
'skew-png': {
title: 'Наклонить PNG',
description: 'Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.',
diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts
index 70412c8..2da23d0 100644
--- a/web/src/lib/registry.ts
+++ b/web/src/lib/registry.ts
@@ -51,7 +51,10 @@ import {
type RgbChannel
} from './core/color';
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
-import { decodeSvgText, decodeTextImage, jpegRoundtrip, toBase64, toDataUrl, type OutputMime } from './core/io';
+import { decodeSvgText, decodeTextImage, jpegRoundtrip, toBase64, toDataUrl, type OutputMime } from './core/io';
+import { drawTextBlock, type TextFont } from './core/domText';
+import { formatStamp } from './core/datefmt';
+import type { Position9 } from './core/textdraw';
import { hexToPixels, pixelsToHex } from './core/text';
import { clonePixelImage, type PixelImage } from './core/types';
@@ -81,8 +84,9 @@ export type ParamDef =
options: { value: string; label: string }[];
default: string;
}
- | { id: string; label: string; type: 'checkbox'; default: boolean }
- | { id: string; label: string; type: 'color'; default: string };
+ | { id: string; label: string; type: 'checkbox'; default: boolean }
+ | { id: string; label: string; type: 'color'; default: string }
+ | { id: string; label: string; type: 'text'; default: string; placeholder?: string };
export type OutputFormat = {
mime: OutputMime;
@@ -107,10 +111,12 @@ export type ToolEntry = {
img: PixelImage,
params: Record
) => Promise | PixelImage;
- popularity?: number;
- icon?: string;
- resultType?: 'image' | 'info' | 'text';
- output?: OutputFormat;
+ popularity?: number;
+ icon?: string;
+ resultType?: 'image' | 'info' | 'text';
+ output?: OutputFormat;
+ /** Инструменту нужен DOM (canvas): исполняется только напрямую, без воркера. */
+ domOnly?: boolean;
};
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
@@ -127,12 +133,20 @@ function num(params: Record, id: string): number {
return v;
}
-function str(params: Record, id: string): string {
- const v = params[id];
- if (typeof v !== 'string') {
- throw new ToolError('errors.paramString', { id });
- }
- return v;
+function str(params: Record, id: string): string {
+ const v = params[id];
+ if (typeof v !== 'string') {
+ throw new ToolError('errors.paramString', { id });
+ }
+ return v;
+}
+
+function bool(params: Record, id: string): boolean {
+ const v = params[id];
+ if (typeof v !== 'boolean') {
+ throw new ToolError('errors.paramBool', { id });
+ }
+ return v;
}
function decodeToPng(id: string, title: string, description: string): ToolEntry {
@@ -855,7 +869,141 @@ export const TOOLS: ToolEntry[] = [
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
)
},
- {
+ {
+ 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,
+ params: [
+ { id: 'text', label: 'Text', type: 'text', default: 'Hello!', placeholder: 'Your text' },
+ { id: 'fontSize', label: 'Font size, px', type: 'slider', min: 8, max: 200, step: 1, default: 48 },
+ { id: 'color', label: 'Text color', type: 'color', default: '#ffffff' },
+ {
+ id: 'font',
+ label: 'Font',
+ type: 'select',
+ default: 'sans',
+ options: [
+ { value: 'sans', label: 'Sans-serif' },
+ { value: 'serif', label: 'Serif' },
+ { value: 'mono', label: 'Monospace' }
+ ]
+ },
+ { id: 'bold', label: 'Bold', type: 'checkbox', default: true },
+ {
+ id: 'position',
+ label: 'Position',
+ type: 'select',
+ default: 'bottom-right',
+ 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' }
+ ]
+ },
+ { id: 'margin', label: 'Margin, px', type: 'slider', min: 0, max: 200, step: 1, default: 24 },
+ { id: 'plate', label: 'Backing plate', type: 'checkbox', default: false },
+ { id: 'plateColor', label: 'Plate color', type: 'color', default: '#000000' },
+ {
+ id: 'plateOpacity',
+ label: 'Plate opacity, %',
+ type: 'slider',
+ min: 0,
+ max: 100,
+ step: 5,
+ default: 60
+ }
+ ],
+ run: (img, p) =>
+ drawTextBlock(img, {
+ text: str(p, 'text'),
+ fontSize: num(p, 'fontSize'),
+ font: str(p, 'font') as TextFont,
+ bold: bool(p, 'bold'),
+ color: str(p, 'color'),
+ opacityPercent: 100,
+ position: str(p, 'position') as Position9,
+ margin: num(p, 'margin'),
+ plateColor: bool(p, 'plate') ? str(p, 'plateColor') : undefined,
+ plateOpacityPercent: num(p, 'plateOpacity')
+ })
+ },
+ {
+ 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,
+ params: [
+ { id: 'format', label: 'Format', type: 'text', default: 'YYYY-MM-DD', placeholder: 'YYYY-MM-DD hh:mm' },
+ { id: 'fontSize', label: 'Font size, px', type: 'slider', min: 8, max: 200, step: 1, default: 32 },
+ { id: 'color', label: 'Text color', type: 'color', default: '#ffffff' },
+ {
+ id: 'font',
+ label: 'Font',
+ type: 'select',
+ default: 'mono',
+ options: [
+ { value: 'sans', label: 'Sans-serif' },
+ { value: 'serif', label: 'Serif' },
+ { value: 'mono', label: 'Monospace' }
+ ]
+ },
+ { id: 'bold', label: 'Bold', type: 'checkbox', default: false },
+ {
+ id: 'position',
+ label: 'Position',
+ type: 'select',
+ default: 'bottom-right',
+ 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' }
+ ]
+ },
+ { id: 'margin', label: 'Margin, px', type: 'slider', min: 0, max: 200, step: 1, default: 20 },
+ { id: 'plate', label: 'Backing plate', type: 'checkbox', default: true },
+ { id: 'plateColor', label: 'Plate color', type: 'color', default: '#000000' },
+ {
+ id: 'plateOpacity',
+ label: 'Plate opacity, %',
+ type: 'slider',
+ min: 0,
+ max: 100,
+ step: 5,
+ default: 55
+ }
+ ],
+ run: (img, p) =>
+ drawTextBlock(img, {
+ text: formatStamp(new Date(), str(p, 'format')),
+ fontSize: num(p, 'fontSize'),
+ font: str(p, 'font') as TextFont,
+ bold: bool(p, 'bold'),
+ color: str(p, 'color'),
+ opacityPercent: 100,
+ position: str(p, 'position') as Position9,
+ margin: num(p, 'margin'),
+ plateColor: bool(p, 'plate') ? str(p, 'plateColor') : undefined,
+ plateOpacityPercent: num(p, 'plateOpacity')
+ })
+ },
+ {
id: 'png-is-grayscale',
title: 'Check: is PNG grayscale?',
description: 'Reports whether the image consists only of shades of gray.',
@@ -1058,9 +1206,12 @@ export function sanitizeParams(
case 'checkbox':
out[param.id] = typeof raw === 'boolean' ? raw : param.default;
break;
- case 'color':
- out[param.id] =
- typeof raw === 'string' && /^#[0-9a-f]{6}$/i.test(raw) ? raw : param.default;
+ case 'color':
+ out[param.id] =
+ typeof raw === 'string' && /^#[0-9a-f]{6}$/i.test(raw) ? raw : param.default;
+ break;
+ case 'text':
+ out[param.id] = typeof raw === 'string' ? raw : param.default;
break;
}
}
diff --git a/web/src/lib/tools/executor.ts b/web/src/lib/tools/executor.ts
index f80a530..d122e61 100644
--- a/web/src/lib/tools/executor.ts
+++ b/web/src/lib/tools/executor.ts
@@ -3,6 +3,7 @@ import { ToolError } from '../core/errors';
type MaybeRunnable = {
id: string;
+ domOnly?: boolean;
run?: (img: PixelImage, params: Record) => Promise | PixelImage;
};
@@ -14,6 +15,9 @@ export async function executeStep(
if (!tool.run) {
throw new Error('errors.noImageRun');
}
+ if (tool.domOnly) {
+ return await runDirect(tool, img, params);
+ }
if (typeof Worker === 'undefined') {
return await runDirect(tool, img, params);
}
diff --git a/web/src/lib/tools/tool-icons.ts b/web/src/lib/tools/tool-icons.ts
index 54694e0..230d458 100644
--- a/web/src/lib/tools/tool-icons.ts
+++ b/web/src/lib/tools/tool-icons.ts
@@ -2,6 +2,7 @@ import {
AppWindow,
Binary,
Blend,
+ CalendarDays,
ClipboardPaste,
Contrast,
Crop,
@@ -37,6 +38,7 @@ import {
SearchCheck,
Square,
Sun,
+ Type,
ZoomIn,
ZoomOut
} from '@lucide/svelte';
@@ -94,6 +96,8 @@ export const TOOL_ICONS: Record = {
'sharpen-png': Focus,
'remove-background-png': Scissors,
'add-stroke-png': Square,
+ 'add-text-png': Type,
+ 'date-stamp-png': CalendarDays,
'find-contour-png': Scan,
'make-thicker-png': ZoomIn,
'make-thinner-png': ZoomOut,