From cc70750727aaf5d5c758aa64fa577262a5197b9b Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Mon, 7 Sep 2026 09:09:29 +0500 Subject: [PATCH] feat: add analyze, generate, text tools and update plan --- docs/plan-migrate-remaining.md | 129 ++++++---------- web/src/lib/registry-new/analyze.ts | 166 ++++++++++++++++++++- web/src/lib/registry-new/generate.ts | 213 ++++++++++++++++++++++++++- web/src/lib/registry-new/text.ts | 65 +++++++- 4 files changed, 488 insertions(+), 85 deletions(-) diff --git a/docs/plan-migrate-remaining.md b/docs/plan-migrate-remaining.md index 32317c0..3d9fa54 100644 --- a/docs/plan-migrate-remaining.md +++ b/docs/plan-migrate-remaining.md @@ -1,7 +1,9 @@ # План: дожать перевод инструментов в preview и переезд ветки `old/` -> Статус: **план.** Основа — `plan-composite-params.md` (Фазы 1–5). Текущее -> состояние: **40 из 125 инструментов переведены в `registry-new`**, старый UI +> Статус: **переведено всё простое, осталось 23 спец-случая.** Основа — +> `plan-composite-params.md` (Фазы 1–5). Текущее +> состояние: **101 инструмент из 125 переведены в `registry-new`** (включая +> ранее переведённые 40; из оставшихся 85 перенесены 62 простых), старый UI > уже живёт на `(old)/`-маршрутах, линтер-изоляция old↔preview на месте > (плагин `isolation/no-mixed-imports`). Здесь — что сделать, чтобы: > @@ -16,13 +18,14 @@ ## Текущая картина (факты) -- `registry.ts` + `registry/` — **125 инструментов**; `registry-new/` — **40**; - остаток — **85**. (Ранний подсчёт «104» был занижен: извлечение суффиксом - `-png` теряло каналы `png-to-hsl/…`, текстовые вердикты `png-info` и т.п. и - обратные конвертеры `png-to-base64/…`.) -- Preview (`/preview`) показывает только переведённые 40; старый UI работает +- `registry.ts` + `registry/` — **125 инструментов**; `registry-new/` — **101**; + остаток — **23 спец-случая**. (Ранний подсчёт «104» был занижен: извлечение + суффиксом `-png` теряло каналы `png-to-hsl/…`, текстовые вердикты + `png-info` и т.п. и обратные конвертеры `png-to-base64/…`.) +- Preview (`/preview`) показывает переведённые 101; старый UI работает на `/`-маршрутах (группа `(old)/`): `+page`, `demo`, `list-tools`, - `tools/[id]`. + `tools/[id]`. Все «простые» инструменты переведены (color 18, filters 3, + geometry 13, alpha 13, generate 8, analyze-маски 6, text 1). - Плагин `isolation/no-mixed-imports` (`web/eslint-plugins/isolation/`) полностью разделяет ветки: старый `lib/registry.ts|/registry/**`, `lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`, @@ -39,48 +42,20 @@ `ColorField.svelte`, пустой ruleset в `Toggle.svelte`, неиспользуемые токены в `preview.css`. -## Скоуп перевода: 85 инструментов осталось +## Скоуп перевода: осталось 23 особых случая -По категориям (в скобках — сколько из категории уже переведено; подсчёт по -`registry/` vs `registry-new/`, без учёта генерируемых `SpaceId` и params): +Все инструменты без специальных фич переведены (суммарно 62 простых из 85, +плюс ранее переведённые 40). Остаток — 23 инструмента, требующих достройки +фундамента preview. По категориям: -- **color — 18** (из 25, переведено 7): grayscale-png, invert-colors-png, - adjust-brightness-contrast-png, auto-contrast-png, sepia-png, posterize-png, - black-and-white-png, decrease-color-count-png, extract-channel-png, - swap-channels-png, change-png-opacity, change-png-hue, плюс 6 канальных - `png-to-{hsl,hsv,hsi,cmyk,ycbcr,lab}` (генерируются из `SPACES`); - `compress-png`/`reduce-to-size-png` — нет, они в convert. Простое; канальные — - на `core/channels.ts` (`renderSpace`). Нюанс: `change-png-opacity`/`hue` - похожи на переведённые, но это отдельные инструменты, не дубли. -- **geometry — 13** (из 18): rotate, flip, skew, zoom, shift, add-padding, tile, - trim-empty-space, change-aspect-ratio, swap-orientation, symmetric-copy, - center-by-alpha, rotate-free. В основном простое (slider/select/color/checkbox). - Нюансы: `rotate-free` — угол 0..360 (посмотреть reuse `AngleControl` или - slider); `tile` — cols/rows (два числа, можно `dimension` или два slider); - `shift` — offsetX/Y в px + цвет (не путать с `offset`-процентами). -- **alpha — 14** (из 21): remove-alpha, set-alpha, extract-alpha-mask, - round-corners, invert-alpha, remove-background, make-thicker, make-thinner, - feather-edges, clean-edges, harden-alpha, despeckle-alpha, close-holes + - **`watermark-image-png`** (см. особый случай). Остальное простое. -- **generate — 10** (из 21): emoji-to-png (emoji+size); палитровые генераторы - color-wheel, complementary, analogous, triadic, tetradic, monochromatic, - shades (baseColor + count/spread — простое); **`mix-colors`** и - **`sort-colors`** — особые случаи (списки). -- **convert — 14** (из 16): text-source-инструменты - base64/data-uri/hex/bytes/rgb-values-to-png + обратные - png-to-base64/data-uri/hex/bytes/rgb-values/bmp — **особый случай** (нужен - text-источник в preview); `compress-png`/`reduce-to-size-png` — **особый - случай** (output/формат, связан с backlog №7); `svg-to-png` — width. -- **analyze — 12** (из 13): show-transparent, show-grayscale, show-color, - light/dark-pixel-mask, unique-color-mask (color+opacity+tolerance+mode) — - простое, частично переиспользуемый предикат; **`verify-is-png`** — текстовый - вердикт, особый случай; плюс 5 text-вердиктов `png-info`, `png-is-grayscale`, - `png-file-size`, `png-is-transparent`, `png-orientation` — тоже текстовый - вывод (см. text-source/вердикт-механику). -- **filters — 3** (из 8): blur (radius), sharpen (strength), silhouette - (color+threshold). Простое. -- **text — 1** (из 3): **`watermark-tile-png`** — text+font-style+opacity+angle+ - stepX/stepY. Переиспользует font-style; угол — AngleControl-подобный. +- **alpha — 1**: `watermark-image-png`. +- **analyze — 6**: text-вердикты `png-info`, `png-is-grayscale`, + `png-file-size`, `png-is-transparent`, `png-orientation`, `verify-is-png`. +- **generate — 2**: `mix-colors-png`, `sort-colors-png` (списки значений). +- **convert — 14**: text-source-конвертеры + `base64/data-uri/hex/bytes/rgb-values-to-png` + обратные + `png-to-base64/…`; output/формат `compress-png`, `reduce-to-size-png`, + `png-to-bmp`; `svg-to-png` (text-source + DOM). ### Особые случаи (требуют достройки фундамента, не просто перевод) @@ -89,19 +64,20 @@ применяется поверх. В новом preview это единственный непереведённый инструмент с источником-картинкой помимо основного входа. Учесть связь с `overlay-store` (упомянут в `plan-redesign.md`). -2. **Text-source / text-вердикты** (base64/data-uri/hex/bytes/rgb-values-to-png - и обратные png-to-base64/…; аналитические `png-info`, `png-is-grayscale`, - `png-file-size`, `png-is-transparent`, `png-orientation`, `verify-is-png`) — - в старом UI это `text`-параметры, из которых собирается изображение, либо - текстовый результат вместо картинки. В новом preview нужно решить, как - подаётся текст-вход (текстовое поле/паста на странице инструмента) и как - показывается результат для вердиктных инструментов (в конверсиях — не - картинка, а текст/бейдж). -3. **Output/формат** (`compress-png`, `reduce-to-size-png`; пересекается с - `convert-png-to-jpg`/`webp`) — по `backlog.md` №7 формат переезжает в - кнопку Download, а не в отдельный инструмент. Перед переводом решить: - переводим как инструменты с quality-параметром или дожидаемся достройки - download-формата. +2. **Text-source / text-вердикты** (вход: `base64/data-uri/hex/bytes/ + rgb-values-to-png`, `svg-to-png`; выход-вердикт: `png-to-base64/…`, + аналитические `png-info`, `png-is-grayscale`, `png-file-size`, + `png-is-transparent`, `png-orientation`, `verify-is-png`) — тип `ToolEntry` + уже имеет `toText`/`textToText`/`runFromText`, но executor превью их **не + вызывает** (только `run`/`generate`). Нужно решить, как подаётся текст-вход + (текстовое поле/паста на странице инструмента) и как показывается результат + для вердиктных инструментов (в конверсиях — не картинка, а текст/бейдж), и + протащить эти поля через executor/UI. +3. **Output/формат** (`compress-png`, `reduce-to-size-png`, `png-to-bmp`; + пересекается с `convert-png-to-jpg`/`webp`) — по `backlog.md` №7 формат + переезжает в кнопку Download, а не в отдельный инструмент. Перед переводом + решить: переводим как инструменты с quality-параметром или дожидаемся + достройки download-формата. 4. **`mix-colors` / `sort-colors`** — нужен список значений (цвета/веса или источник). В схеме нет list-kind: либо новый kind, либо фиксированное число слотов, либо отложить до отдельной задачи про списки. @@ -119,20 +95,16 @@ a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающ ## Шаги перевода остатка (порядок) -1. **Простые пачки** (основной объём, 1 инструмент = 1 диф, паттерн Фазы 2): - color (18) → filters (3) → geometry простое → alpha (кроме watermark-image). -2. **Analyze-маски и вердикты** (12): переиспользование предикатов - `core/masks` в новом `registry-new/analyze.ts`; `show-color-pixels` — аналог - переведённого `extract-color-from-png` (уже в registry-new); text-вердикты - (png-info, png-is-grayscale, png-file-size, png-is-transparent, - png-orientation, verify-is-png) — вместе с text-source-механикой (п. 5). -3. **Generate-палитры** (8 без mix/sort): генераторы с палитрой (как - `color-spectrum`/`step-colors`, кнопка Generate). -4. **Raw-пакеты** (watermark-tile): font-style + угол + dimension(+), - сверить с `add-text` (переведён). -5. **Особые случаи** — отдельным переговоренным решением (overlay для - watermark-image, text-source, output-формат, списки для mix/sort). -6. После категории — `schema.layout` для сгруппированных инструментов +1. **Простые пачки** — **выполнено**: color (18) → filters (3) → geometry (13) + → alpha (13/14, кроме watermark-image) → generate (8/10) → analyze-маски + (6/12) → text `watermark-tile-png`. Каждая пачка после себя гнала гейты + (eslint/svelte-check/тесты/prettier); в тестах только предсуществующий фейл + `palette.test.ts`. +2. **Особые случаи** — отдельным переговоренным решением (overlay для + watermark-image, text-source/вердикты — через `toText`/`runFromText` в + executor, output-формат, списки для mix/sort). Это единственный реальный + объём для дожатия каталога. +3. После категории — `schema.layout` для сгруппированных инструментов (шаг 32-33 паттерн) и ручная проверка в `/preview`. ## Переезд в `old/` (шаг 37 основного плана) @@ -188,15 +160,12 @@ a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающ | Часть | Сложность | Оценка | | ---------------------------------------------------------------- | --------------- | --------------------- | -| Простые инструменты (color/filters/geometry/alpha без watermark) | Низкая-Средняя | ~5-8ч (≈47 шт) | -| Analyze-маски (12, часть в п.5 с text-source) | Средняя | ~2ч | -| Generate-палитры (8) | Средняя | ~2-3ч | -| watermark-tile (text) | Средняя | ~1ч | +| Простые инструменты (color/filters/geometry/alpha/generate/маски/text) | Низкая-Средняя | **выполнено** (62 шт) | | Особые случаи (overlay/text-source/format/списки) | Средняя-Высокая | ~4-6ч + ревью решения | | Долги (ToolCard, palette.test.ts, lint:all остаток) | Средняя | ~2-4ч | | Переезд в `old/` + конфиг плагина + гейты | Средняя-Низкая | ~2-4ч | | Проверка/фидбек + правки | Зависит | ~3-6ч | -| **Итого** | | **~21-34ч** по фазам | +| **Итого** | | **~11-20ч** по фазам | > Оценки ориентировочные; особые случаи — с ревью решения, объём финальных > правок по фидбеку не предсказуем. diff --git a/web/src/lib/registry-new/analyze.ts b/web/src/lib/registry-new/analyze.ts index 657bb07..4a9047b 100644 --- a/web/src/lib/registry-new/analyze.ts +++ b/web/src/lib/registry-new/analyze.ts @@ -1,6 +1,12 @@ import type { ToolEntry } from "./types"; import { field, toolSchema } from "../registry-schema"; -import { extractByColor } from "../core/masks"; +import { + extractByColor, + isGrayscaleish, + luma01, + rarityPredicate, + renderPredicateMask, +} from "../core/masks"; interface ExtractColorParams { color: string; @@ -22,4 +28,160 @@ const extractColor: ToolEntry = { run: (img, p) => extractByColor(img, p.color, p.tolerance), }; -export const analyzeEntries = [extractColor]; +interface MaskParams { + mode: "binary" | "highlight"; + color: string; + opacity: number; +} + +const maskBaseFields = { + mode: field.select({ + default: "binary", + options: [ + { value: "binary", label: "Black & white mask" }, + { value: "highlight", label: "Color highlight" }, + ], + }), + color: field.color({ default: "#ff00aa" }), + opacity: field.slider({ min: 0, max: 100, step: 5, default: 70 }), +}; + +function renderMask( + img: Parameters[0], + p: MaskParams, + predicate: (r: number, g: number, b: number, a: number) => boolean, +) { + return renderPredicateMask(img, predicate, { + mode: p.mode, + color: p.color, + opacityPercent: p.opacity, + }); +} + +export const showTransparentSchema = toolSchema({ + ...maskBaseFields, + mode: field.select({ + default: "highlight", + options: [ + { value: "binary", label: "Black & white mask" }, + { value: "highlight", label: "Color highlight" }, + ], + }), +}); + +const showTransparent: ToolEntry = { + id: "show-transparent-png", + title: "Show Transparent Areas PNG", + description: + "Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.", + category: "analyze", + schema: showTransparentSchema, + run: (img, p) => renderMask(img, p, (_r, _g, _b, a) => a < 255), +}; + +interface GrayscalePixelsParams extends MaskParams { + tolerance: number; +} + +export const showGrayscalePixelsSchema = toolSchema({ + ...maskBaseFields, + tolerance: field.slider({ min: 0, max: 64, step: 1, default: 0 }), +}); + +const showGrayscalePixels: ToolEntry = { + id: "show-grayscale-pixels-png", + title: "Show Grayscale Pixels PNG", + description: + "Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.", + category: "analyze", + schema: showGrayscalePixelsSchema, + run: (img, p) => + renderMask(img, p, (r, g, b) => isGrayscaleish(r, g, b, p.tolerance)), +}; + +interface ColorPixelsParams extends MaskParams { + tolerance: number; +} + +export const showColorPixelsSchema = toolSchema({ + ...maskBaseFields, + tolerance: field.slider({ min: 0, max: 64, step: 1, default: 8 }), +}); + +const showColorPixels: ToolEntry = { + id: "show-color-pixels-png", + title: "Show Color Pixels PNG", + description: + "Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.", + category: "analyze", + schema: showColorPixelsSchema, + run: (img, p) => + renderMask(img, p, (r, g, b) => !isGrayscaleish(r, g, b, p.tolerance)), +}; + +interface LightPixelParams extends MaskParams { + threshold: number; +} + +export const lightPixelMaskSchema = toolSchema({ + ...maskBaseFields, + threshold: field.slider({ min: 0, max: 100, step: 1, default: 70 }), +}); + +const lightPixelMask: ToolEntry = { + id: "light-pixel-mask-png", + title: "Light Pixel Mask PNG", + description: "Selects pixels brighter than the luminance threshold.", + category: "analyze", + schema: lightPixelMaskSchema, + run: (img, p) => + renderMask(img, p, (r, g, b) => luma01(r, g, b) >= p.threshold / 100), +}; + +interface DarkPixelParams extends MaskParams { + threshold: number; +} + +export const darkPixelMaskSchema = toolSchema({ + ...maskBaseFields, + threshold: field.slider({ min: 0, max: 100, step: 1, default: 30 }), +}); + +const darkPixelMask: ToolEntry = { + id: "dark-pixel-mask-png", + title: "Dark Pixel Mask PNG", + description: "Selects pixels darker than the luminance threshold.", + category: "analyze", + schema: darkPixelMaskSchema, + run: (img, p) => + renderMask(img, p, (r, g, b) => luma01(r, g, b) <= p.threshold / 100), +}; + +interface UniqueColorParams extends MaskParams { + rarity: number; +} + +export const uniqueColorMaskSchema = toolSchema({ + ...maskBaseFields, + rarity: field.slider({ min: 1, max: 50, step: 1, default: 1 }), +}); + +const uniqueColorMask: ToolEntry = { + id: "unique-color-mask-png", + title: "Unique Color Mask PNG", + description: + "Selects colors that occur no more than the given number of times — rare and one-off pixels.", + category: "analyze", + schema: uniqueColorMaskSchema, + run: (img, p) => renderMask(img, p, rarityPredicate(img, p.rarity)), +}; + +export const analyzeEntries = [ + extractColor, + showTransparent, + showGrayscalePixels, + showColorPixels, + lightPixelMask, + darkPixelMask, + uniqueColorMask, +]; diff --git a/web/src/lib/registry-new/generate.ts b/web/src/lib/registry-new/generate.ts index 77b14f1..b65f877 100644 --- a/web/src/lib/registry-new/generate.ts +++ b/web/src/lib/registry-new/generate.ts @@ -1,13 +1,20 @@ -import { renderTextToImage } from "../core/domText"; +import { renderEmoji, renderTextToImage } from "../core/domText"; import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools"; import { noiseImage, solidImage } from "../core/generate"; import { changeCanvasSize } from "../core/geometry"; import type { PixelImage } from "../core/types"; import { + analogousSet, + complementarySet, hexToRgb, + monochromaticSet, renderBlend, renderSwatches, + renderWheel, + shadeSet, stepColors, + tetradicSet, + triadicSet, } from "../core/palette"; import { field, @@ -439,6 +446,202 @@ const stepColorsTool: ToolEntry = { ), }; +interface EmojiToPngParams { + emoji: string; + size: number; +} + +export const emojiToPngSchema = toolSchema({ + emoji: field.text({ default: "😀" }), + size: field.slider({ min: 32, max: 1024, step: 16, default: 256 }), +}); + +const emojiToPng: ToolEntry = { + id: "emoji-to-png", + title: "Emoji to PNG", + description: + "Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.", + category: "generate", + domOnly: true, + schema: emojiToPngSchema, + generate: (p) => renderEmoji(p.emoji, Math.round(p.size)), +}; + +interface ColorWheelParams { + size: number; + lightness: number; +} + +export const colorWheelSchema = toolSchema({ + size: field.slider({ min: 128, max: 1024, step: 16, default: 512 }), + lightness: field.slider({ min: 0, max: 100, step: 1, default: 50 }), +}); + +const colorWheelTool: ToolEntry = { + id: "color-wheel-png", + title: "Color Wheel PNG", + description: + "Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.", + category: "generate", + schema: colorWheelSchema, + generate: (p) => renderWheel(p.size, p.lightness), +}; + +interface PaletteBaseParams { + baseColor: string; + width: number; + layout: "strip" | "grid"; +} + +const paletteBaseSchema = { + baseColor: field.color({ default: "#2563eb" }), + width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }), + layout: field.select({ + default: "grid", + options: [ + { value: "grid", label: "Grid" }, + { value: "strip", label: "Strip" }, + ], + }), +}; + +const paletteLayoutGroup = { + layout: { + groups: [ + { title: "Base color", fields: ["baseColor"] }, + { + title: "Output", + cols: 2, + fields: ["width", "layout"], + }, + ], + }, +}; + +const complementaryTool: ToolEntry = { + id: "complementary-png", + title: "Complementary Palette PNG", + description: + "Two opposite colors on the color wheel — the base and its complement.", + category: "generate", + schema: toolSchema( + { ...paletteBaseSchema }, + { ...paletteLayoutGroup }, + ), + generate: (p) => + renderSwatches(complementarySet(p.baseColor), p.width, p.layout), +}; + +const triadicTool: ToolEntry = { + id: "triadic-png", + title: "Triadic Palette PNG", + description: "Three colors evenly spaced 120° apart on the color wheel.", + category: "generate", + schema: toolSchema( + { + ...paletteBaseSchema, + baseColor: field.color({ default: "#ff0000" }), + }, + { ...paletteLayoutGroup }, + ), + generate: (p) => renderSwatches(triadicSet(p.baseColor), p.width, p.layout), +}; + +const tetradicTool: ToolEntry = { + id: "tetradic-png", + title: "Tetradic Palette PNG", + description: + "Four colors in two complementary pairs, 90° apart on the wheel.", + category: "generate", + schema: toolSchema( + { + ...paletteBaseSchema, + baseColor: field.color({ default: "#8000ff" }), + }, + { ...paletteLayoutGroup }, + ), + generate: (p) => renderSwatches(tetradicSet(p.baseColor), p.width, p.layout), +}; + +interface AnalogousParams extends PaletteBaseParams { + spread: number; + count: number; +} + +const analogousTool: ToolEntry = { + id: "analogous-png", + title: "Analogous Palette PNG", + description: + "Neighboring hues around the base color — calm, related color scheme.", + category: "generate", + schema: toolSchema( + { + ...paletteBaseSchema, + baseColor: field.color({ default: "#22c55e" }), + spread: field.slider({ min: 10, max: 90, step: 5, default: 30 }), + count: field.slider({ min: 3, max: 9, step: 1, default: 5 }), + }, + { ...paletteLayoutGroup }, + ), + generate: (p) => + renderSwatches( + analogousSet(p.baseColor, p.spread, p.count), + p.width, + p.layout, + ), +}; + +interface MonochromaticParams extends PaletteBaseParams { + count: number; + range: number; +} + +const monochromaticTool: ToolEntry = { + id: "monochromatic-png", + title: "Monochromatic Palette PNG", + description: + "Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.", + category: "generate", + schema: toolSchema( + { + ...paletteBaseSchema, + baseColor: field.color({ default: "#0ea5e9" }), + count: field.slider({ min: 2, max: 9, step: 1, default: 5 }), + range: field.slider({ min: 10, max: 90, step: 5, default: 40 }), + }, + { ...paletteLayoutGroup }, + ), + generate: (p) => + renderSwatches( + monochromaticSet(p.baseColor, p.count, p.range), + p.width, + p.layout, + ), +}; + +interface ShadesParams extends PaletteBaseParams { + count: number; + depth: number; +} + +const shadesTool: ToolEntry = { + id: "shades-png", + title: "Shade Ramp PNG", + description: "A ramp of the base color getting darker step by step.", + category: "generate", + schema: toolSchema( + { + ...paletteBaseSchema, + baseColor: field.color({ default: "#f59e0b" }), + count: field.slider({ min: 2, max: 9, step: 1, default: 5 }), + depth: field.slider({ min: 10, max: 90, step: 5, default: 50 }), + }, + { ...paletteLayoutGroup }, + ), + generate: (p) => + renderSwatches(shadeSet(p.baseColor, p.count, p.depth), p.width, p.layout), +}; + interface TextToPngParams { text: string; style: FontStyle; @@ -505,5 +708,13 @@ export const generateEntries = [ placeholder, blendTwo, stepColorsTool, + emojiToPng, + colorWheelTool, + complementaryTool, + triadicTool, + tetradicTool, + analogousTool, + monochromaticTool, + shadesTool, textToPng, ]; diff --git a/web/src/lib/registry-new/text.ts b/web/src/lib/registry-new/text.ts index c0c8cc5..ba07039 100644 --- a/web/src/lib/registry-new/text.ts +++ b/web/src/lib/registry-new/text.ts @@ -1,5 +1,5 @@ import { formatStamp } from "../core/datefmt"; -import { drawTextBlock } from "../core/domText"; +import { drawTextBlock, drawTextTile } from "../core/domText"; import type { Position9 } from "../core/textdraw"; import type { FontStyle, Plate } from "../registry-schema"; import { field, toolSchema } from "../registry-schema"; @@ -122,4 +122,65 @@ const dateStamp: ToolEntry = { }), }; -export const textEntries = [addText, dateStamp]; +interface WatermarkTileParams { + text: string; + style: FontStyle; + opacity: number; + angle: number; + stepX: number; + stepY: number; +} + +const watermarkTileSchema = toolSchema( + { + text: field.text({ default: "DRAFT", placeholder: "Watermark text" }), + style: field.fontStyle({ + min: 12, + max: 160, + size: 56, + font: "sans", + bold: true, + color: "#ffffff", + }), + opacity: field.slider({ min: 5, max: 100, step: 5, default: 30 }), + angle: field.slider({ min: -90, max: 90, step: 1, default: -30 }), + stepX: field.slider({ min: 40, max: 600, step: 10, default: 220 }), + stepY: field.slider({ min: 40, max: 600, step: 10, default: 180 }), + }, + { + layout: { + groups: [ + { title: "Watermark", fields: ["text", "style", "opacity"] }, + { + title: "Tile", + cols: 2, + fields: ["angle", "stepX", "stepY"], + }, + ], + }, + }, +); + +const watermarkTile: ToolEntry = { + id: "watermark-tile-png", + title: "Watermark Tile PNG", + description: + "Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.", + category: "text", + domOnly: true, + schema: watermarkTileSchema, + run: (img, p) => + drawTextTile(img, { + text: p.text, + fontSize: p.style.size, + font: p.style.font, + bold: p.style.bold, + color: p.style.color, + opacityPercent: p.opacity, + stepX: p.stepX, + stepY: p.stepY, + angleDeg: p.angle, + }), +}; + +export const textEntries = [addText, dateStamp, watermarkTile];