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,