Compare commits
14
Commits
6e0647dfbf
...
2b67414e79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b67414e79 | ||
|
|
56ebe5b53b | ||
|
|
3c1c5b2b3a | ||
|
|
84cc56cc6c | ||
|
|
d65adcd16c | ||
|
|
553601acc1 | ||
|
|
5c37eb2e7b | ||
|
|
db02cbd165 | ||
|
|
11fc160997 | ||
|
|
116d7c72aa | ||
|
|
b277f3c66d | ||
|
|
54af7626d6 | ||
|
|
38e7cd20d2 | ||
|
|
2e980fb115 |
@@ -0,0 +1,44 @@
|
||||
# План: волна 4 — текст, штампы, водяные знаки
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 25.08.2026.**
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Четыре инструмента новой категории «Текст»: надпись на изображении, дата-штамп, защитная плитка (повторяющийся полупрозрачный текст под углом), водяной знак картинкой. Каталог вырастает с 69 до 73. Это первые операции, рисующие через canvas 2D — класс perceptual по роадмапу: точные пиксельные тесты на них не пишутся, юнит-тестами покрывается чистая математика раскладки.
|
||||
|
||||
## 2. Решения
|
||||
|
||||
- Вся геометрия — чистые функции без DOM: якорь из 9-позиционной сетки → координаты, перенос текста по ширине с инъекцией измерителя, раскладка плитки с углом и шагом. Canvas-вызовы — тонкий слой рядом (`core/textdraw.ts`), в юнит-тесты не попадает.
|
||||
- Один движок рендера текста на все три текстовых инструмента: шрифт из системного набора (sans/serif/mono), размер, цвет, жирность, прозрачность, поворот. Различия инструментов — только в пресетах параметров и количестве слоёв (одна надпись vs плитка).
|
||||
- Дата-штамп — надпись с автотекстом: строка формата (`YYYY-MM-DD hh:mm`) разворачивается в текущую дату в момент запуска; свой мини-форматтер без зависимостей.
|
||||
- Водяной знак картинкой — первый двухисточниковый инструмент: вторая картинка грузится отдельной зоной в этапе, живёт только в состоянии страницы и сознательно не сохраняется ни в workspace-pipeline, ни в экспорте JSON; после восстановления цепочки знак нужно выбрать заново. Ограничение фиксируем в описании инструмента.
|
||||
- Новая категория `text` встаёт после «Фильтров»; названия в обоих словарях, порядок в CATEGORIES один.
|
||||
|
||||
## 3. Инструменты
|
||||
|
||||
- `add-text-png` — параметры: текст (строка), размер (слайдер 8–200), цвет, шрифт (select), жирность (чекбокс), позиция (select 3×3: углы/стороны/центр), отступ (слайдер). Опциональная подложка: цвет + прозрачность плашки за текстом.
|
||||
- `date-stamp-png` — тот же набор минус текст, плюс формат даты; дефолт — нижний правый угол.
|
||||
- `watermark-tile-png` — текст, размер, цвет, прозрачность (0–100), угол (-90…90), шаг по X/Y; плитка заполняет весь холст.
|
||||
- `watermark-image-png` — второй источник (загрузка PNG с альфой), масштаб знака (% от ширины холста), прозрачность, позиция 3×3, отступ.
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными test/check/build и ревью.
|
||||
|
||||
- **A. Фундамент** — категория `text` (categories + словари + порядок), `core/textdraw.ts`: anchor9, wrap, tileLayout с юнит-тестами на математике (координаты углов/центра, переносы слов, плотность плитки), тонкий canvas-модуль `domText.ts` (без тестов). Реестр пока пуст в категории.
|
||||
- **B. Надпись и дата** — `add-text-png`, `date-stamp-png` на общем движке; иконки, переводы, записи реестра. Смоук: обе локали, тёмная тема (плашка читаема), длинный текст переносится и не вылезает за край.
|
||||
- **C. Плитка** — `watermark-tile-png`; смоук: отрицательные и нулевой угол, прозрачность 0/50/100, мелкий шаг не подвешивает страницу на больших холстах (ограничение количества плиток капом).
|
||||
- **D. Знак картинкой** — двухисточниковый UX: вторая drop-зона в этапе (компонент рядом с SourceCard, виден только у этого инструмента), масштаб/позиция/отступ; ограничение про сохранение — в описании и подсказке. Этап режется без ущерба остальным, если решим отложить.
|
||||
- **E. Итог** — полный прогон, смоук §6, архивация плана.
|
||||
|
||||
## 5. Критерии готовности
|
||||
|
||||
- 73 инструмента; вся математика раскладки покрыта юнит-тестами; canvas-слой изолирован в одном модуле.
|
||||
- test/check/build зелёные; новые инструменты работают в цепочке как обычные шаги (кроме источника знака — см. ограничение).
|
||||
|
||||
## 6. Что сознательно не делаем
|
||||
|
||||
- Выбор произвольных пользовательских шрифтов (файлами) и веб-шрифтов — только системный набор.
|
||||
- Кернинг/линии/многострочное редактирование с абзацами — один абзац с автопереносом.
|
||||
- Сохранение картинки-знака в пайплайне и её передача между сессиями.
|
||||
- Текст по контуру, градиентные заливки текста.
|
||||
+1
-4
@@ -1,9 +1,5 @@
|
||||
# Backlog
|
||||
|
||||
## Кандидат следующей волны
|
||||
|
||||
Текст и водяные знаки — add-text, watermark, штампы (см. roadmap фаза 1.5 и план wave3 §7).
|
||||
|
||||
## Идеи
|
||||
|
||||
1. **Менять первый инструмент цепочки** — оценка M.
|
||||
@@ -11,6 +7,7 @@
|
||||
|
||||
2. **UI-эксперимент: параметры между исходником и результатом** — оценка S/M.
|
||||
Раскладка «Исходник → Параметры → Результат» в один ряд вместо параметров отдельным блоком снизу. Проверить на узких экранах; возможно за флагом/A-B, чтобы сравнить с текущей.
|
||||
Ждет нового дизайна
|
||||
|
||||
3. **Сворачивать инструменты в chain** — оценка S.
|
||||
Тоггл сворачивания звена до заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости помнить в workspace-pipeline.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# План: закрытие EASY/MEDIUM-пробелов из сравнения с onlinepngtools
|
||||
|
||||
> Статус: черновик на ревью
|
||||
|
||||
## Принципы
|
||||
|
||||
- Волны собираются вокруг общего математического ядра: одна волна = один движок + N инструментов на нём.
|
||||
- Каждая волна оформляется отдельным планом при старте; здесь — очередь и состав.
|
||||
- Порядок: сначала кластеры, дающие много инструментов малой кровью, затем одиночные эффекты, MEDIUM-пакеты в конце.
|
||||
- Полный список параметров каждого инструмента живёт в карте инструментов (tools-map.md); здесь только состав и ядро.
|
||||
|
||||
## Очередь волн
|
||||
|
||||
### W1. Палитры — ВЫПОЛНЕНА (11 инструментов)
|
||||
|
||||
Ядро: RGB↔HSL + гармонии цветового круга. Вывод: свотч-полотно (генератор).
|
||||
Состав: color-wheel, complementary, monochromatic, analogous, triadic, tetradic, similar-shades, sort-colors.
|
||||
Плюс утилиты смешения тем же ядром: mix-colors, average-color, blend-two, step-between (+4, итого 12).
|
||||
|
||||
### W2. Каналы и пространства — ВЫПОЛНЕНА (6 инструментов)
|
||||
|
||||
Ядро: матрицы преобразования RGB→(HSL/HSV/HSI/CMYK/YCbCr/LAB) + визуализация выбранного компонента серым или окрашенно.
|
||||
Общий select «компонент» + select «режим отображения».
|
||||
|
||||
### W3. Маски по свойствам пикселей — ВЫПОЛНЕНА (7 инструментов)
|
||||
|
||||
Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом).
|
||||
Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask, unique-color-mask, extract-by-color.
|
||||
|
||||
### W4. Фигурные маски — ВЫПОЛНЕНА (4 инструмента)
|
||||
|
||||
Ядро: SDF фигуры (круг/квадрат/звезда/волна) → альфа-маска с fit-режимами.
|
||||
Состав: circle-mask, square-mask, star-mask, wavy-mask.
|
||||
|
||||
### W5. Геометрия-добивки — ВЫПОЛНЕНА (5 инструментов)
|
||||
|
||||
Ядро: bbox по альфе (trim) переиспользуется тремя инструментами источника.
|
||||
Состав: trim-empty-space (закрывает remove border/space), change-canvas-size, change-aspect-ratio, landscape↔portrait, symmetric-copy.
|
||||
|
||||
### W6. Эффекты лёгкие — ВЫПОЛНЕНА (6 инструментов; color-blocks покрыт pixelate)
|
||||
|
||||
Состав: pixelate, color-blocks, randomize-pixels (seed), add-noise, feather-edges, clean-edges, silhouette.
|
||||
Shadow/glow — сюда же, если потянет этап: оба = размытая альфа + смещение + цвет (ядро blur уже есть).
|
||||
|
||||
### W7. Конвертеры и генераторы — ВЫПОЛНЕНА (11 инструментов)
|
||||
|
||||
PNG↔bytes, PNG↔rgb-values (текстовые результаты), verify-is-png (анализ сигнатуры),
|
||||
text-to-png (domText без входной картинки), emoji-to-png, placeholder-png,
|
||||
color-spectrum, colorful-random (seed), draw-grid.
|
||||
multi-color-gradient — если успеем новый тип параметра «список цветов», иначе перенос.
|
||||
|
||||
### W8. Цветовые MEDIUM — 4 инструмента, L
|
||||
|
||||
Ядро: квантование (median-cut или k-means).
|
||||
Состав: quantize (k), decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список цветов — нужен тип параметра «список»), dithering (Floyd–Steinberg/Bayer поверх квантования).
|
||||
|
||||
### W9. Сжатие — 4–5 инструментов, L, риск
|
||||
|
||||
Без wasm честное управление размером PNG ограничено: реальный рычаг — квантование (W8) + обрезка метаданных + итеративный подбор под целевой KB.
|
||||
Состав: compress (пресеты усилий), reduce-to-size (целевой KB, бинарный поиск по k), optimize (re-encode), change-quality/low-quality — переосмыслить как пресеты W8; jpeg-artifacts уже покрывает «испортить».
|
||||
|
||||
## Вне очереди
|
||||
|
||||
- Region-инструменты (censor/erase/pixelate-area/blur-area/sharpen-area/reverse-area) — ждут UI выделения области на превью; отдельное решение.
|
||||
- Мультифайловый вывод (split-parts, gif-frames, separate-colors) — ждут механизм «результат = набор файлов».
|
||||
- Анимационные (slow-reveal/fade/scrolling) — выход не PNG; отдельное решение о формате.
|
||||
- HARD: glitch, barcode, signature-extract, handwritten-digital.
|
||||
- Нишевые серии logo/icon/stamp/signature — не копируем.
|
||||
|
||||
## Оценка масштаба
|
||||
|
||||
W1–W7 дают **~47 новых инструментов**, почти все EASY. W8 добавляет ядро квантования (открывает custom-palette/dithering и улучшает W9). Суммарно каталог достигает ~120+ при текущих 71.
|
||||
@@ -0,0 +1,207 @@
|
||||
# Карта инструментов: реализовано · добавить · идеи
|
||||
|
||||
> Живой документ для сверки с каталогом. Формат записи: `id / название — параметры через запятую`.
|
||||
> Источник сравнения: onlinepngtools.com (311 уникальных; 107 из них — нишевые клоны серий Logo*/Icon*/Stamp*/Signature* поверх обычных операций).
|
||||
|
||||
---
|
||||
|
||||
## 1. Реализовано
|
||||
|
||||
### Конвертация
|
||||
|
||||
- jpg-to-png — без параметров
|
||||
- webp-to-png — без параметров
|
||||
- gif-to-png — первый кадр
|
||||
- bmp-to-png — без параметров
|
||||
- ico-to-png — выбор размера кадра
|
||||
- png-to-bmp — 24-бит, фон вместо альфы
|
||||
- convert-png-to-jpg — background, quality
|
||||
- convert-png-to-webp — quality
|
||||
- svg-to-png — width результата
|
||||
- png-to-base64 / base64-to-png — строка
|
||||
- png-to-data-uri / data-uri-to-png — строка
|
||||
- png-to-hex — rrggbbaa по строкам / hex-to-png — tokens + width
|
||||
- png-to-bytes — десятичные RGBA-байты по строкам / bytes-to-png — tokens + width
|
||||
- png-to-rgb-values — rgba(r,g,b,a) по пикселям / rgb-values-to-png — числа + width
|
||||
- svg-to-png — width результата
|
||||
- verify-is-png — текстовый источник (base64/data-uri), вердикт по сигнатуре
|
||||
|
||||
### Прозрачность
|
||||
|
||||
- change-png-opacity — percent
|
||||
- set-alpha-channel-png — percent
|
||||
- remove-alpha-channel-png — без параметров
|
||||
- extract-alpha-mask-png — без параметров
|
||||
- invert-alpha-png — без параметров
|
||||
- remove-background-png — color, tolerance, outerOnly, smooth
|
||||
- remove-color-from-png — targetColor, tolerance
|
||||
- add-stroke-png — color, thickness
|
||||
- find-contour-png — color, thickness
|
||||
- make-thicker-png / make-thinner-png — radius
|
||||
- harden-alpha-png — threshold
|
||||
- feather-edges-png — radius (размытие только альфы)
|
||||
- clean-edges-png — radius (defringe: RGB от ближайшего непрозрачного)
|
||||
- watermark-image-png — вторая картинка-знак (загружается на странице), scale, opacity, position, margin
|
||||
- despeckle-alpha-png / close-holes-png — radius
|
||||
- center-by-alpha-png — без параметров
|
||||
- round-corners-png — radius
|
||||
- circle-mask-png — size (диаметр, % меньшей стороны), offsetX, offsetY
|
||||
- square-mask-png — widthPct, heightPct, offsetX, offsetY
|
||||
- star-mask-png — points, innerRadius, size, rotation, offsetX, offsetY
|
||||
- wavy-mask-png — size, amplitude, waves, phase, offsetX, offsetY
|
||||
|
||||
### Цвет
|
||||
|
||||
- grayscale-png / invert-colors-png / sepia-png / auto-contrast-png — без параметров
|
||||
- adjust-brightness-contrast-png — brightness, contrast
|
||||
- change-png-hue — degrees
|
||||
- extract-channel-png — channel (r/g/b)
|
||||
- swap-channels-png — pair (r-g/r-b/g-b)
|
||||
- black-and-white-png — threshold
|
||||
- posterize-png — levels
|
||||
- two-colors-png — lightColor, darkColor, threshold
|
||||
- temperature-png — percent
|
||||
- gamma-png — value
|
||||
- tint-png — color, strength
|
||||
|
||||
### Разложение каналов
|
||||
|
||||
- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray | space-as-rgb)
|
||||
- png-to-cmyk — component (c/m/y/k), display
|
||||
- png-to-ycbcr — component (y/cb/cr), display
|
||||
- png-to-lab — component (l/a/b), display
|
||||
|
||||
### Геометрия
|
||||
|
||||
- resize-png — width (0=авто), height (0=авто), keepAspect
|
||||
- crop-png — x, y, width, height
|
||||
- rotate-png — angle (90/180/270)
|
||||
- flip-png — axis (h/v)
|
||||
- skew-png — degX, degY
|
||||
- rotate-free-png — angle
|
||||
- zoom-png — scale
|
||||
- shift-png — offsetX, offsetY, color фона
|
||||
- add-padding-png — padding, transparent, color
|
||||
- add-border-png — thickness, color
|
||||
- fit-on-background-png — width, height, transparent, color
|
||||
- tile-png — columns, rows
|
||||
- trim-empty-space-png — threshold (альфа)
|
||||
- change-canvas-size-png — width, height, anchor (3×3)
|
||||
- change-aspect-ratio-png — ratio (пресеты), mode (crop/pad)
|
||||
- swap-orientation-png — target (portrait/landscape)
|
||||
- symmetric-copy-png — axis, keepSide
|
||||
|
||||
### Фильтры
|
||||
|
||||
- blur-png — radius
|
||||
- sharpen-png — strength
|
||||
- vignette-png — strength
|
||||
- jpeg-artifacts-png — quality (имитация пережатия jpg/webp)
|
||||
- pixelate-png — blockSize (закрывает и их Color Blocks)
|
||||
- randomize-pixels-png — blockSize, seed
|
||||
- add-noise-png — amount, mode (mono/color), seed
|
||||
- silhouette-png — color, threshold
|
||||
|
||||
### Анализ
|
||||
|
||||
- png-info — размеры, альфа, число цветов
|
||||
- png-is-transparent / png-is-grayscale / png-orientation — текстовый вердикт
|
||||
|
||||
### Маски по свойствам пикселей
|
||||
|
||||
- show-transparent-png — color, opacity (подсветка прозрачных/полупрозрачных)
|
||||
- show-grayscale-pixels-png — tolerance, mode (binary/highlight), highlightColor, highlightOpacity
|
||||
- show-color-pixels-png — tolerance, mode, highlightColor, highlightOpacity
|
||||
- light-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
|
||||
- dark-pixel-mask-png — threshold, mode, highlightColor, highlightOpacity
|
||||
- unique-color-mask-png — rarity (макс. повторов), mode, highlightColor, highlightOpacity
|
||||
- extract-color-from-png — color, tolerance (оставить близкие, остальное прозрачным)
|
||||
|
||||
### Генерация
|
||||
|
||||
- create-empty-png — width, height, transparent, color
|
||||
- single-color-png — width, height, color
|
||||
- random-noise-png — width, height, seed
|
||||
- linear-gradient-png — width, height, fromColor, toColor, direction
|
||||
- text-to-png — text, fontSize, font, bold, color, transparentBg, backgroundColor, padding
|
||||
- emoji-to-png — emoji, size
|
||||
- placeholder-png — width, height, backgroundColor, color, showText
|
||||
- color-spectrum-png — width, height, direction, saturation, lightness
|
||||
- random-colors-png — width, height, blockSize, seed
|
||||
- draw-grid-png — width, height, cols, rows, lineWidth, color, transparentBg
|
||||
|
||||
### Текст
|
||||
|
||||
- add-text-png — text, fontSize, color, font, bold, position (3×3), margin, plate, plateColor, plateOpacity
|
||||
- date-stamp-png — format, fontSize, color, font, bold, position, margin, plate, plateColor, plateOpacity
|
||||
- watermark-tile-png — text, fontSize, color, opacity, angle, stepX, stepY, font, bold
|
||||
|
||||
### Палитры и цветовые утилиты
|
||||
|
||||
- color-wheel-generator — size, кольца/сектора, показ hex при клике (у нас — статичный свотч-полотно)
|
||||
- complementary-palette — baseColor
|
||||
- monochromatic-palette — baseColor, count
|
||||
- analogous-palette — baseColor, spread
|
||||
- triadic-palette — baseColor
|
||||
- tetradic-palette — baseColor
|
||||
- similar-shades-palette — baseColor, count, range
|
||||
- sort-colors — источник (палитра-картинка или список), порядок (hsl/luma)
|
||||
- mix-colors — colors[], веса?
|
||||
- average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps (три частных случая одного движка)
|
||||
|
||||
---
|
||||
|
||||
## 2. Можно добавить — из onlinepngtools
|
||||
|
||||
### Разложение каналов — остаток
|
||||
|
||||
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея)
|
||||
|
||||
### Края и силуэт — остаток
|
||||
|
||||
- glow — radius, color, intensity
|
||||
- shadow — offsetX, offsetY, blur, color, alpha
|
||||
|
||||
### Эффекты — остаток
|
||||
|
||||
- censor-region / erase-region — область (MEDIUM: нужен UI выделения → см. идеи)
|
||||
- whirl — угол, центр, радиус (MEDIUM)
|
||||
|
||||
### Сортировка/блоки пикселей
|
||||
|
||||
- sort-pixels — blockSize, ключ (яркость/канал), направление (MEDIUM)
|
||||
- slow-reveal / fade-in / fade-out / disappear — анимационные (→ идеи)
|
||||
|
||||
### Сжатие и качество (5, все MEDIUM)
|
||||
|
||||
- compress-png — уровень усилий
|
||||
- reduce-file-size — целевой размер KB (итеративный поиск)
|
||||
- optimize-png — пресеты
|
||||
- change-quality — честная семантика для lossless (см. план-гапы §риск)
|
||||
- low-quality-png — частично покрыт jpeg-artifacts; остаток = сильный quantize
|
||||
|
||||
### Генераторы — остаток
|
||||
|
||||
- multi-color-gradient — список стопов (нужен новый тип параметра → MEDIUM-UI)
|
||||
|
||||
### Конвертеры — остаток
|
||||
|
||||
- png-to-gif — MEDIUM (однокадровый GIF-энкодер руками)
|
||||
- gif-to-frames — MEDIUM (мультифайловый вывод → идея)
|
||||
- change-bit-depth — MEDIUM (пересборка PNG)
|
||||
|
||||
### Прочее единичное
|
||||
|
||||
- pick-a-color — пипетка уже есть в превью; отдельная страница не планируется (покрыто)
|
||||
- preview/test на цветных фонах — фича превью, не инструмент (решить позже)
|
||||
- extract-barcode — HARD, вне планов
|
||||
|
||||
---
|
||||
|
||||
## 3. Идеи на рассмотрение (нужна архитектура или спорная ценность)
|
||||
|
||||
- **Region-инструменты** — требуется UI выделения прямоугольника/ластика на превью: censor-region, erase-region, pixelate-area, blur-area, sharpen-area, reverse-colors-area. Один раз делаем selection-компонент — получаем сразу шесть инструментов.
|
||||
- **Мультифайловый вывод** — сейчас инструмент отдаёт одну картинку: split-into-parts, gif-to-frames, separate-colors, multiply-grid-as-files. Нужен механизм «результат = набор файлов» (zip?).
|
||||
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling: это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
|
||||
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital, extract-barcode.
|
||||
- **Нишевые серии** (logo/icon/stamp/signature — 107 клонов у источника) — сознательно не копируем: это обычные операции над конкретным контентом, у нас они доступны через базовые инструменты + цепочки.
|
||||
@@ -5,7 +5,8 @@ export type CategoryId =
|
||||
| 'geometry'
|
||||
| 'analyze'
|
||||
| 'generate'
|
||||
| 'filters';
|
||||
| 'filters'
|
||||
| 'text';
|
||||
|
||||
/**
|
||||
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
|
||||
@@ -17,6 +18,7 @@ export const CATEGORIES: readonly CategoryId[] = [
|
||||
'color',
|
||||
'geometry',
|
||||
'filters',
|
||||
'text',
|
||||
'analyze',
|
||||
'generate'
|
||||
];
|
||||
|
||||
@@ -75,6 +75,14 @@
|
||||
pipetteActive={pipetteTargetId === param.id}
|
||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
||||
/>
|
||||
{:else if param.type === 'text'}
|
||||
<TextField
|
||||
id={param.id}
|
||||
label={paramLabel(tool, param)}
|
||||
type="text"
|
||||
placeholder={param.placeholder}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import ToolSearch from './search/ToolSearch.svelte';
|
||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
||||
import { executeStep } from '$lib/tools/executor';
|
||||
import { clearOverlay, setOverlay } from '$lib/tools/overlay-store.svelte';
|
||||
import { t } from '$lib/i18n/t';
|
||||
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
|
||||
import type { StageStatus } from './stage/stage-props';
|
||||
@@ -51,6 +52,7 @@
|
||||
let showMask = $state(false);
|
||||
let info = $state<ImageInfo | null>(null);
|
||||
let errorText = $state('');
|
||||
let overlayImage = $state<PixelImage | null>(null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let values = $state<Record<string, any>>({ ...defaultParams(tool), ...presetBaseValues });
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -151,9 +153,22 @@
|
||||
}
|
||||
|
||||
async function handleTextSubmit(text: string) {
|
||||
if (!isTextSource || !tool.runFromText) return;
|
||||
if (!isTextSource) return;
|
||||
errorText = '';
|
||||
status = 'processing';
|
||||
if (tool.textToText) {
|
||||
try {
|
||||
result = null;
|
||||
previewResult = null;
|
||||
info = null;
|
||||
textResult = await tool.textToText(text);
|
||||
status = 'loaded';
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!tool.runFromText) return;
|
||||
try {
|
||||
source = await tool.runFromText(text, defaultParams(tool));
|
||||
values = defaultParams(tool);
|
||||
@@ -272,7 +287,16 @@
|
||||
info = null;
|
||||
errorText = '';
|
||||
status = 'idle';
|
||||
values = defaultParams(tool);
|
||||
clearOverlay();
|
||||
values = { ...defaultParams(tool), ...presetBaseValues };
|
||||
}
|
||||
|
||||
async function handleOverlayFile(file: File) {
|
||||
try {
|
||||
setOverlay(await decodeFile(file));
|
||||
} catch (e) {
|
||||
errorText = errorMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
@@ -332,6 +356,10 @@
|
||||
handlePickColor={handlePickColor}
|
||||
showError={showError}
|
||||
errorMessage={errorMessage}
|
||||
overlayImage={overlayImage}
|
||||
onOverlayFile={(f) => void handleOverlayFile(f)}
|
||||
onOverlayError={(e) => (errorText = errorMessage(e))}
|
||||
onOverlayClear={clearOverlay}
|
||||
/>
|
||||
|
||||
<div class="chain-stack">
|
||||
@@ -349,7 +377,7 @@
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
<ToolSearch onSelect={(id) => applyChainTool(index, id)} />
|
||||
<ToolSearch onSelect={(id) => applyChainTool(index, id)} chainableOnly />
|
||||
</div>
|
||||
{:else if getTool(step.toolId)}
|
||||
{@const stepTool = getTool(step.toolId)!}
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
|
||||
interface Props {
|
||||
onSelect: (toolId: string) => void;
|
||||
/** true — только инструменты, пригодные в шаг цепочки (слот добавления шага). */
|
||||
chainableOnly?: boolean;
|
||||
}
|
||||
|
||||
let { onSelect }: Props = $props();
|
||||
let { onSelect, chainableOnly = false }: Props = $props();
|
||||
|
||||
const candidates = TOOLS.filter(isChainable);
|
||||
const candidates = $derived(chainableOnly ? TOOLS.filter(isChainable) : TOOLS);
|
||||
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import DownloadButton from '../DownloadButton.svelte';
|
||||
import Button from '../ui/Button.svelte';
|
||||
import EmptyState from '../ui/EmptyState.svelte';
|
||||
import OverlayCard from '../tool/OverlayCard.svelte';
|
||||
import ParamsCard from '../tool/ParamsCard.svelte';
|
||||
import ResultCard from '../tool/ResultCard.svelte';
|
||||
import SourceCard from '../tool/SourceCard.svelte';
|
||||
@@ -43,6 +44,10 @@
|
||||
handlePipetteToggle?: (id: string) => void;
|
||||
handlePickColor?: (hex: string) => void;
|
||||
showError?: (e: unknown) => void;
|
||||
overlayImage?: PixelImage | null;
|
||||
onOverlayFile?: (file: File) => void;
|
||||
onOverlayError?: (e: unknown) => void;
|
||||
onOverlayClear?: () => void;
|
||||
index?: number;
|
||||
input?: PixelImage | null;
|
||||
result?: PixelImage | null;
|
||||
@@ -81,6 +86,10 @@
|
||||
handlePipetteToggle,
|
||||
handlePickColor,
|
||||
showError,
|
||||
overlayImage = null,
|
||||
onOverlayFile,
|
||||
onOverlayError,
|
||||
onOverlayClear,
|
||||
index = 0,
|
||||
input = null,
|
||||
result = null,
|
||||
@@ -138,6 +147,14 @@
|
||||
pipetteActive={!!pipetteTargetId}
|
||||
onPickColor={(hex) => handlePickColor?.(hex)}
|
||||
/>
|
||||
{#if tool.needsOverlaySource && source}
|
||||
<OverlayCard
|
||||
overlay={overlayImage}
|
||||
onFile={(f) => onOverlayFile?.(f)}
|
||||
onError={(e) => onOverlayError?.(e)}
|
||||
onClear={() => onOverlayClear?.()}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/t';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import DropZone from '../DropZone.svelte';
|
||||
import Preview from '../Preview.svelte';
|
||||
import Button from '../ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
overlay: PixelImage | null;
|
||||
onFile: (file: File) => void;
|
||||
onError: (e: unknown) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
let { overlay, onFile, onError, onClear }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="overlay-card">
|
||||
<span class="edge-legend overlay-legend" aria-hidden="true">{t('ui.overlayTitle')}</span>
|
||||
{#if !overlay}
|
||||
<DropZone {onFile} {onError} label={t('ui.overlayDrop')} />
|
||||
{:else}
|
||||
<div class="preview-wrap">
|
||||
<Preview image={overlay} />
|
||||
<Button variant="secondary" onclick={onClear}>{t('ui.overlayRemove')}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.overlay-legend {
|
||||
top: -0.65em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.overlay-card :global(.dropzone) {
|
||||
min-height: 7rem;
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
|
||||
.preview-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preview-wrap :global(.media) {
|
||||
max-height: 12rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<input class="control" {id} {type} min={min} max={max} step={step} bind:value />
|
||||
<input class="control" {id} {type} min={min} max={max} step={step} {placeholder} bind:value />
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
input.control::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hexToRgb } from './palette';
|
||||
import { renderSpace, SPACES } from './channels';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('преобразования пространств', () => {
|
||||
it('hsl красного: h=0, s=1, l=0.5', () => {
|
||||
const [h, s, l] = SPACES.hsl.convert(hexToRgb('#ff0000'));
|
||||
expect(h).toBeCloseTo(0);
|
||||
expect(s).toBeCloseTo(1);
|
||||
expect(l).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('hsv белого: v=1, чёрного: v=0', () => {
|
||||
expect(SPACES.hsv.convert({ r: 255, g: 255, b: 255 })[2]).toBeCloseTo(1);
|
||||
expect(SPACES.hsv.convert({ r: 0, g: 0, b: 0 })[2]).toBe(0);
|
||||
});
|
||||
|
||||
it('hsi серого: s=0', () => {
|
||||
const [, s] = SPACES.hsi.convert({ r: 128, g: 128, b: 128 });
|
||||
expect(s).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('cmyk: белый — 0,0,0,0; чёрный — 0,0,0,1', () => {
|
||||
const w = SPACES.cmyk.convert({ r: 255, g: 255, b: 255 });
|
||||
const b = SPACES.cmyk.convert({ r: 0, g: 0, b: 0 });
|
||||
expect(w.every((v) => Math.abs(v) < 1e-9)).toBe(true);
|
||||
expect(b[3]).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it('ycbcr белого: y≈1, cb≈cr≈0.5', () => {
|
||||
const [y, cb, cr] = SPACES.ycbcr.convert({ r: 255, g: 255, b: 255 });
|
||||
expect(y).toBeCloseTo(1);
|
||||
expect(cb).toBeCloseTo(0.5, 2);
|
||||
expect(cr).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
it('lab белого: l≈1, a≈b≈0.5 (центр)', () => {
|
||||
const [l, a, bb] = SPACES.lab.convert({ r: 255, g: 255, b: 255 });
|
||||
expect(l).toBeCloseTo(1, 2);
|
||||
expect(a).toBeCloseTo(0.5, 2);
|
||||
expect(bb).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderSpace', () => {
|
||||
const red = makeImage(1, 1, [[255, 0, 0, 255]]);
|
||||
|
||||
it('gray-режим: компонент y (жёлтый) чистого красного = белый', () => {
|
||||
const out = renderSpace(red, 'cmyk', 'y', 'gray');
|
||||
expect([...out.data]).toEqual([255, 255, 255, 255]);
|
||||
});
|
||||
|
||||
it('color-режим hsl красного: каналы = тон/насыщенность/светлота', () => {
|
||||
const out = renderSpace(red, 'hsl', 's', 'color');
|
||||
expect(out.data[0]).toBe(0);
|
||||
expect(out.data[1]).toBe(255);
|
||||
expect(out.data[2]).toBeGreaterThanOrEqual(127);
|
||||
});
|
||||
|
||||
it('неизвестное пространство или компонент дают прозрачную заглушку', () => {
|
||||
const junk = makeImage(1, 1, [[10, 20, 30, 255]]);
|
||||
expect(renderSpace(junk, 'cmyk' as never, 'zz' as never, 'gray').data[3]).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PixelImage } from './types';
|
||||
import { createPixelImage } from './types';
|
||||
import { rgbToHsl } from './palette';
|
||||
import type { Rgb } from './palette';
|
||||
|
||||
/** Все компоненты нормализованы в 0..1 в порядке объявления. */
|
||||
export type SpaceComponents = number[];
|
||||
|
||||
export type SpaceId = 'hsl' | 'hsv' | 'hsi' | 'cmyk' | 'ycbcr' | 'lab';
|
||||
|
||||
function hueOf({ r, g, b }: Rgb): number {
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
if (max === min) return 0;
|
||||
const d = max - min;
|
||||
let h: number;
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||
else if (max === g) h = ((b - r) / d + 2) * 60;
|
||||
else h = ((r - g) / d + 4) * 60;
|
||||
return h;
|
||||
}
|
||||
|
||||
function rgbToHsv({ r, g, b }: Rgb): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
return [hueOf({ r, g, b }), max === 0 ? 0 : (max - min) / max, max];
|
||||
}
|
||||
|
||||
function rgbToHsi({ r, g, b }: Rgb): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const sum = r + g + b;
|
||||
const intensity = sum / 3;
|
||||
if (sum === 0) return [0, 0, 0];
|
||||
const min = Math.min(r, g, b);
|
||||
const saturation = 1 - min / intensity;
|
||||
return [hueOf({ r, g, b }), saturation, intensity];
|
||||
}
|
||||
|
||||
function rgbToCmyk({ r, g, b }: Rgb): [number, number, number, number] {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const k = 1 - Math.max(rn, gn, bn);
|
||||
if (k === 1) return [0, 0, 0, 1];
|
||||
return [(1 - rn - k) / (1 - k), (1 - gn - k) / (1 - k), (1 - bn - k) / (1 - k), k];
|
||||
}
|
||||
|
||||
function rgbToYcbcr({ r, g, b }: Rgb): [number, number, number] {
|
||||
const y = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
const cb = 128 - 0.168736 * r - 0.331264 * g + 0.5 * b;
|
||||
const cr = 128 + 0.5 * r - 0.418688 * g - 0.081312 * b;
|
||||
return [y / 255, cb / 255, cr / 255];
|
||||
}
|
||||
|
||||
function srgbTransfer(v: number): number {
|
||||
return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
function rgbToLab({ r, g, b }: Rgb): [number, number, number] {
|
||||
const lin = [r, g, b].map((v) => srgbTransfer(v / 255));
|
||||
const x = (lin[0] * 0.4124 + lin[1] * 0.3576 + lin[2] * 0.1805) / 0.95047;
|
||||
const y = lin[0] * 0.2126 + lin[1] * 0.7152 + lin[2] * 0.0722;
|
||||
const z = (lin[0] * 0.0193 + lin[1] * 0.1192 + lin[2] * 0.9505) / 1.08883;
|
||||
const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||
const fx = f(x);
|
||||
const fy = f(y);
|
||||
const fz = f(z);
|
||||
// L нормирован 0..1; a/b центрированы на 0.5 с размахом ±0.5
|
||||
return [(116 * fy - 16) / 100, (500 * (fx - fy)) / 250 + 0.5, (200 * (fy - fz)) / 250 + 0.5];
|
||||
}
|
||||
|
||||
type SpaceDef = { components: string[]; convert: (rgb: Rgb) => SpaceComponents };
|
||||
|
||||
export const SPACES: Record<SpaceId, SpaceDef> = {
|
||||
hsl: {
|
||||
components: ['h', 's', 'l'],
|
||||
convert: ({ r, g, b }) => {
|
||||
const { h, s, l } = rgbToHsl({ r, g, b });
|
||||
return [h / 360, s, l];
|
||||
}
|
||||
},
|
||||
hsv: { components: ['h', 's', 'v'], convert: rgbToHsv },
|
||||
hsi: { components: ['h', 's', 'i'], convert: rgbToHsi },
|
||||
cmyk: { components: ['c', 'm', 'y', 'k'], convert: rgbToCmyk },
|
||||
ycbcr: { components: ['y', 'cb', 'cr'], convert: rgbToYcbcr },
|
||||
lab: { components: ['l', 'a', 'b'], convert: rgbToLab }
|
||||
};
|
||||
|
||||
export type ChannelDisplay = 'gray' | 'color';
|
||||
|
||||
/**
|
||||
* Визуализация выбранного пространства: каждый компонент пространства
|
||||
* попадает в свой канал результата (mode 'color') или выбранный компонент
|
||||
* рисуется градациями серого (mode 'gray').
|
||||
*/
|
||||
export function renderSpace(
|
||||
img: PixelImage,
|
||||
space: SpaceId,
|
||||
component: string,
|
||||
display: ChannelDisplay
|
||||
): PixelImage {
|
||||
const def = SPACES[space];
|
||||
if (!def) return createPixelImage(img.width, img.height);
|
||||
const idx = def.components.indexOf(component);
|
||||
if (idx < 0) return createPixelImage(img.width, img.height);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const comps = def.convert({ r: img.data[i], g: img.data[i + 1], b: img.data[i + 2] });
|
||||
const di = i;
|
||||
out.data[di + 3] = img.data[i + 3];
|
||||
if (display === 'gray') {
|
||||
const v = comps[idx] * 255;
|
||||
out.data[di] = v;
|
||||
out.data[di + 1] = v;
|
||||
out.data[di + 2] = v;
|
||||
} else {
|
||||
out.data[di] = comps[0] * 255;
|
||||
out.data[di + 1] = (comps[1] ?? 0) * 255;
|
||||
out.data[di + 2] = (comps[2] ?? 0) * 255;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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М');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ToolError } from './errors';
|
||||
import type { PixelImage } from './types';
|
||||
import { anchorOrigin, tileGrid, wrapText, type Position9 } from './textdraw';
|
||||
|
||||
export type TextFont = 'sans' | 'serif' | 'mono';
|
||||
|
||||
const FONT_STACKS: Record<TextFont, string> = {
|
||||
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
mono: 'ui-monospace, "Cascadia Code", Consolas, monospace'
|
||||
};
|
||||
|
||||
function ctx2d(w: number, h: number): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new ToolError('errors.noCanvasCtx');
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
function toPixelImage(canvas: HTMLCanvasElement): PixelImage {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new ToolError('errors.noCanvasCtx');
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
||||
}
|
||||
|
||||
export function fontString(size: number, font: TextFont, bold: boolean): string {
|
||||
return `${bold ? '700 ' : '400 '}${size}px ${FONT_STACKS[font]}`;
|
||||
}
|
||||
|
||||
export interface TextBlockOptions {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
opacityPercent: number;
|
||||
position: Position9;
|
||||
margin: number;
|
||||
maxWidthPercent?: number;
|
||||
plateColor?: string;
|
||||
plateOpacityPercent?: number;
|
||||
angleDeg?: number;
|
||||
}
|
||||
|
||||
/** Одна надпись (с автопереносом и опциональной плашкой) поверх изображения. */
|
||||
export function drawTextBlock(img: PixelImage, o: TextBlockOptions): PixelImage {
|
||||
const { canvas, ctx } = ctx2d(img.width, img.height);
|
||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
|
||||
|
||||
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
||||
const maxWidth = ((o.maxWidthPercent ?? 90) / 100) * img.width;
|
||||
const lines = wrapText(o.text, maxWidth, (s) => ctx.measureText(s).width);
|
||||
const lineH = o.fontSize * 1.25;
|
||||
const ascent = o.fontSize * 0.8;
|
||||
const blockW = Math.min(
|
||||
maxWidth,
|
||||
lines.reduce((max, s) => Math.max(max, ctx.measureText(s).width), 0)
|
||||
);
|
||||
const blockH = lines.length * lineH;
|
||||
|
||||
const origin = anchorOrigin(o.position, blockW, blockH, img.width, img.height, o.margin);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = o.opacityPercent / 100;
|
||||
if (o.angleDeg) {
|
||||
ctx.translate(origin.x + blockW / 2, origin.y + blockH / 2);
|
||||
ctx.rotate((o.angleDeg * Math.PI) / 180);
|
||||
ctx.translate(-(origin.x + blockW / 2), -(origin.y + blockH / 2));
|
||||
}
|
||||
if (o.plateColor && (o.plateOpacityPercent ?? 0) > 0) {
|
||||
ctx.globalAlpha = (o.plateOpacityPercent ?? 0) / 100;
|
||||
ctx.fillStyle = o.plateColor;
|
||||
ctx.fillRect(origin.x, origin.y, blockW, blockH);
|
||||
ctx.globalAlpha = o.opacityPercent / 100;
|
||||
}
|
||||
ctx.fillStyle = o.color;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
lines.forEach((line, i) => {
|
||||
ctx.fillText(line, origin.x, origin.y + ascent + i * lineH);
|
||||
});
|
||||
ctx.restore();
|
||||
|
||||
return toPixelImage(canvas);
|
||||
}
|
||||
|
||||
export interface TileTextOptions extends Omit<TextBlockOptions, 'position' | 'margin' | 'angleDeg'> {
|
||||
stepX: number;
|
||||
stepY: number;
|
||||
angleDeg: number;
|
||||
}
|
||||
|
||||
export interface TextToImageOptions {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
transparentBg: boolean;
|
||||
padding: number;
|
||||
maxTextWidth?: number;
|
||||
}
|
||||
|
||||
/** Картинка из текста: холст подгоняется под размер надписи с паддингом. */
|
||||
export function renderTextToImage(o: TextToImageOptions): PixelImage {
|
||||
const measure = ctx2d(8, 8).ctx;
|
||||
measure.font = fontString(o.fontSize, o.font, o.bold);
|
||||
const maxWidth = o.maxTextWidth ?? 4000;
|
||||
const lines = [o.text];
|
||||
const textW = Math.min(maxWidth, Math.max(measure.measureText(o.text).width, 1));
|
||||
const lineH = o.fontSize * 1.25;
|
||||
|
||||
const w = Math.max(1, Math.ceil(textW + o.padding * 2));
|
||||
const h = Math.max(1, Math.ceil(lineH + o.padding * 2));
|
||||
const { canvas, ctx } = ctx2d(w, h);
|
||||
|
||||
if (!o.transparentBg) {
|
||||
ctx.fillStyle = o.backgroundColor;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
||||
ctx.fillStyle = o.color;
|
||||
ctx.textBaseline = 'top';
|
||||
lines.forEach((line) => ctx.fillText(line, o.padding, o.padding));
|
||||
return toPixelImage(canvas);
|
||||
}
|
||||
|
||||
/** Эмодзи/символ как PNG: рисуется платформенным шрифтом по центру. */
|
||||
export function renderEmoji(symbol: string, size: number): PixelImage {
|
||||
const { canvas, ctx } = ctx2d(size, size);
|
||||
ctx.font = `${Math.round(size * 0.72)}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(symbol, size / 2, size / 2 + size * 0.04);
|
||||
return toPixelImage(canvas);
|
||||
}
|
||||
|
||||
export interface ImageWatermarkOptions {
|
||||
mark: PixelImage;
|
||||
scalePercent: number;
|
||||
opacityPercent: number;
|
||||
position: Position9;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
/** Картинка-знак поверх изображения: масштаб от ширины холста, позиция 3×3. */
|
||||
export function drawImageWatermark(img: PixelImage, o: ImageWatermarkOptions): PixelImage {
|
||||
const { canvas, ctx } = ctx2d(img.width, img.height);
|
||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
|
||||
|
||||
const w = Math.max(1, Math.round((img.width * o.scalePercent) / 100));
|
||||
const h = Math.max(1, Math.round((w * o.mark.height) / o.mark.width));
|
||||
const origin = anchorOrigin(o.position, w, h, img.width, img.height, o.margin);
|
||||
|
||||
const markCanvas = ctx2d(o.mark.width, o.mark.height);
|
||||
markCanvas.ctx.putImageData(
|
||||
new ImageData(new Uint8ClampedArray(o.mark.data), o.mark.width, o.mark.height),
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = o.opacityPercent / 100;
|
||||
ctx.drawImage(markCanvas.canvas, origin.x, origin.y, w, h);
|
||||
ctx.restore();
|
||||
|
||||
return toPixelImage(canvas);
|
||||
}
|
||||
|
||||
/** Повторяющаяся диагональная плитка текста на весь холст. */
|
||||
export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage {
|
||||
const { canvas, ctx } = ctx2d(img.width, img.height);
|
||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
|
||||
|
||||
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
||||
const sample = o.text.length > 0 ? o.text : ' ';
|
||||
const blockW = ctx.measureText(sample).width;
|
||||
const blockH = o.fontSize * 1.4;
|
||||
|
||||
const points = tileGrid(img.width, img.height, o.angleDeg, o.stepX, o.stepY, blockW, blockH);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(img.width / 2, img.height / 2);
|
||||
ctx.rotate((o.angleDeg * Math.PI) / 180);
|
||||
ctx.globalAlpha = o.opacityPercent / 100;
|
||||
ctx.fillStyle = o.color;
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const p of points) {
|
||||
ctx.fillText(sample, p.x - blockW / 2, p.y);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
return toPixelImage(canvas);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
changeCanvasSize,
|
||||
contentBounds,
|
||||
cropToRatio,
|
||||
forceOrientation,
|
||||
padToRatio,
|
||||
symmetricCopy,
|
||||
trimToContent
|
||||
} from './geometry';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const bordered = () => {
|
||||
// 4×4: рамка из прозрачных пикселей вокруг красного центра 2×2
|
||||
const img = makeImage(4, 4, new Array(16).fill([0, 0, 0, 0]));
|
||||
for (let y = 1; y <= 2; y++) {
|
||||
for (let x = 1; x <= 2; x++) {
|
||||
const di = (y * 4 + x) * 4;
|
||||
img.data[di] = 255;
|
||||
img.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return img;
|
||||
};
|
||||
|
||||
describe('contentBounds / trimToContent', () => {
|
||||
it('границы по порогу альфы', () => {
|
||||
expect(contentBounds(bordered(), 0)).toEqual({ x: 1, y: 1, w: 2, h: 2 });
|
||||
});
|
||||
|
||||
it('trim обрезает поля и сохраняет содержимое', () => {
|
||||
const out = trimToContent(bordered(), 0);
|
||||
expect(out.width).toBe(2);
|
||||
expect(out.height).toBe(2);
|
||||
expect(out.data[0]).toBe(255);
|
||||
expect(out.data[3]).toBe(255);
|
||||
});
|
||||
|
||||
it('полностью прозрачное изображение → 1×1', () => {
|
||||
const empty = makeImage(3, 3, new Array(9).fill([0, 0, 0, 0]));
|
||||
const out = trimToContent(empty, 0);
|
||||
expect(out.width).toBe(1);
|
||||
expect(out.height).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeCanvasSize', () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[1, 1, 1, 255],
|
||||
[2, 2, 2, 255],
|
||||
[3, 3, 3, 255],
|
||||
[4, 4, 4, 255]
|
||||
]);
|
||||
|
||||
it('увеличение с якорем center — прозрачные поля со всех сторон', () => {
|
||||
const out = changeCanvasSize(img, 4, 4, 'center');
|
||||
expect(out.width).toBe(4);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect(out.data[(1 * 4 + 1) * 4 + 3]).toBe(255);
|
||||
});
|
||||
|
||||
it('увеличение с якорем top-left — контент прижат в угол', () => {
|
||||
const out = changeCanvasSize(img, 4, 4, 'top-left');
|
||||
expect(out.data[3]).toBe(255);
|
||||
expect(out.data[(3 * 4 + 3) * 4 + 3]).toBe(0);
|
||||
});
|
||||
|
||||
it('уменьшение обрезает как кроп от якоря bottom-right', () => {
|
||||
const out = changeCanvasSize(img, 1, 1, 'bottom-right');
|
||||
expect(out.data[0]).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('соотношение сторон', () => {
|
||||
const wide = makeImage(400, 200, new Array(80000).fill([9, 9, 9, 255]));
|
||||
|
||||
it('cropToRatio 1:1 из 2:1 → квадрат по высоте', () => {
|
||||
const out = cropToRatio(wide, 1);
|
||||
expect(out.width).toBe(200);
|
||||
expect(out.height).toBe(200);
|
||||
});
|
||||
|
||||
it('padToRatio 1:1 из 2:1 → квадрат с прозрачными полями', () => {
|
||||
const out = padToRatio(wide, 1);
|
||||
expect(out.width).toBe(400);
|
||||
expect(out.height).toBe(400);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect(out.data[(200 * 400 + 100) * 4 + 3]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceOrientation / symmetricCopy', () => {
|
||||
it('широкое становится высоким поворотом', () => {
|
||||
const wide = makeImage(300, 100, new Array(30000).fill([5, 5, 5, 255]));
|
||||
const out = forceOrientation(wide, 'portrait');
|
||||
expect(out.width).toBe(100);
|
||||
expect(out.height).toBe(300);
|
||||
});
|
||||
|
||||
it('квадрат не поворачивается', () => {
|
||||
const sq = makeImage(50, 50, new Array(2500).fill([5, 5, 5, 255]));
|
||||
const out = forceOrientation(sq, 'portrait');
|
||||
expect(out.width).toBe(50);
|
||||
expect(out.height).toBe(50);
|
||||
});
|
||||
|
||||
it('симметричная копия удваивает ширину и зеркалит правую половину', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[10, 0, 0, 255],
|
||||
[20, 0, 0, 255]
|
||||
]);
|
||||
const out = symmetricCopy(img, 'vertical', 'left');
|
||||
expect(out.width).toBe(4);
|
||||
expect(out.data[0]).toBe(10);
|
||||
expect(out.data[8]).toBe(20); // пиксель 2 = зеркало начала
|
||||
expect(out.data[12]).toBe(10); // пиксель 3 = зеркало конца
|
||||
});
|
||||
|
||||
it('вертикальная ось удваивает высоту', () => {
|
||||
const img = makeImage(1, 2, [
|
||||
[10, 0, 0, 255],
|
||||
[30, 0, 0, 255]
|
||||
]);
|
||||
const out = symmetricCopy(img, 'horizontal', 'top');
|
||||
expect(out.height).toBe(4);
|
||||
expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // строка 2 = зеркало строки 1
|
||||
expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // строка 3 = зеркало строки 0
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { colorSpectrum, drawGrid, randomColorBlocks } from './gen-tools';
|
||||
|
||||
describe('colorSpectrum', () => {
|
||||
it('горизонтальный: левый край красный (hue 0)', () => {
|
||||
const img = colorSpectrum(100, 10, 'horizontal', 100, 50);
|
||||
expect(img.data[0]).toBeGreaterThan(200);
|
||||
expect(img.data[1]).toBeLessThan(60);
|
||||
});
|
||||
|
||||
it('вертикальный: зелёный максимум около hue 120°', () => {
|
||||
const img = colorSpectrum(10, 100, 'vertical', 100, 50);
|
||||
const rowHue120 = Math.round((120 / 360) * 99);
|
||||
const g = img.data[(rowHue120 * 10 + 5) * 4 + 1];
|
||||
expect(g).toBeGreaterThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('randomColorBlocks', () => {
|
||||
it('детерминирован по seed и блоки однотонные', () => {
|
||||
const a = randomColorBlocks(64, 64, 16, 7);
|
||||
const b = randomColorBlocks(64, 64, 16, 7);
|
||||
expect([...a.data]).toEqual([...b.data]);
|
||||
const c = randomColorBlocks(64, 64, 16, 8);
|
||||
expect([...a.data]).not.toEqual([...c.data]);
|
||||
expect(a.data[3]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawGrid', () => {
|
||||
it('линии на пересечениях непрозрачны, фон прозрачен', () => {
|
||||
const out = drawGrid(100, 100, 4, 4, 2, '#000000', true);
|
||||
expect(out.data[0]).toBe(0);
|
||||
expect(out.data[3]).toBe(255); // (0,0) на линии
|
||||
const mid = ((53 * 100) + 53) * 4;
|
||||
expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн
|
||||
});
|
||||
|
||||
it('белый непрозрачный фон при transparentBg=false', () => {
|
||||
const out = drawGrid(20, 20, 2, 2, 1, '#000000', false);
|
||||
const mid = ((5 * 20) + 5) * 4;
|
||||
expect(out.data[mid]).toBe(255);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createPixelImage, type PixelImage } from './types';
|
||||
import { hslToRgb } from './palette';
|
||||
import { mulberry32 } from './pixel-fx';
|
||||
|
||||
/** Радужный спектр: оттенок 0..360 вдоль выбранной оси. */
|
||||
export function colorSpectrum(
|
||||
width: number,
|
||||
height: number,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
saturationPercent: number,
|
||||
lightnessPercent: number
|
||||
): PixelImage {
|
||||
const out = createPixelImage(width, height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const t = direction === 'vertical' ? y / Math.max(1, height - 1) : x / Math.max(1, width - 1);
|
||||
const { r, g, b } = hslToRgb({
|
||||
h: t * 360,
|
||||
s: saturationPercent / 100,
|
||||
l: lightnessPercent / 100
|
||||
});
|
||||
const di = (y * width + x) * 4;
|
||||
out.data[di] = r;
|
||||
out.data[di + 1] = g;
|
||||
out.data[di + 2] = b;
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Случайные яркие блоки: детерминировано по seed. */
|
||||
export function randomColorBlocks(
|
||||
width: number,
|
||||
height: number,
|
||||
blockSize: number,
|
||||
seed: number
|
||||
): PixelImage {
|
||||
const bs = Math.max(1, Math.round(blockSize));
|
||||
const out = createPixelImage(width, height);
|
||||
const rng = mulberry32(seed);
|
||||
for (let by = 0; by < height; by += bs) {
|
||||
for (let bx = 0; bx < width; bx += bs) {
|
||||
const { r, g, b } = hslToRgb({
|
||||
h: rng() * 360,
|
||||
s: 0.65 + rng() * 0.35,
|
||||
l: 0.45 + rng() * 0.25
|
||||
});
|
||||
const yMax = Math.min(by + bs, height);
|
||||
const xMax = Math.min(bx + bs, width);
|
||||
for (let y = by; y < yMax; y++) {
|
||||
for (let x = bx; x < xMax; x++) {
|
||||
const di = (y * width + x) * 4;
|
||||
out.data[di] = r;
|
||||
out.data[di + 1] = g;
|
||||
out.data[di + 2] = b;
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lineMask(length: number, divisions: number, lineWidth: number): Uint8Array {
|
||||
const mask = new Uint8Array(length);
|
||||
const lw = Math.max(1, Math.round(lineWidth));
|
||||
for (let i = 0; i <= divisions; i++) {
|
||||
const start = Math.round((i * length) / divisions);
|
||||
for (let x = start; x < Math.min(start + lw, length); x++) mask[x] = 1;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
/** Сетка линий на прозрачном или белом фоне. */
|
||||
export function drawGrid(
|
||||
width: number,
|
||||
height: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
lineWidth: number,
|
||||
colorHex: string,
|
||||
transparentBg: boolean
|
||||
): PixelImage {
|
||||
const out = createPixelImage(width, height);
|
||||
if (!transparentBg) out.data.fill(255);
|
||||
|
||||
const m = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
||||
const r = m ? parseInt(m[1].slice(0, 2), 16) : 0;
|
||||
const g = m ? parseInt(m[1].slice(2, 4), 16) : 0;
|
||||
const b = m ? parseInt(m[1].slice(4, 6), 16) : 0;
|
||||
|
||||
const colMask = lineMask(width, Math.max(1, cols), lineWidth);
|
||||
const rowMask = lineMask(height, Math.max(1, rows), lineWidth);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (!colMask[x] && !rowMask[y]) continue;
|
||||
const di = (y * width + x) * 4;
|
||||
out.data[di] = r;
|
||||
out.data[di + 1] = g;
|
||||
out.data[di + 2] = b;
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseHex } from './alpha';
|
||||
import { parseHex } from './alpha';
|
||||
import { ToolError } from './errors';
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
@@ -213,3 +213,157 @@ export function sampleBilinear(
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type Anchor9 =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'middle-left'
|
||||
| 'center'
|
||||
| 'middle-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right';
|
||||
|
||||
/** Границы контента: пиксели с альфой строго больше порога. Пустое изображение → null. */
|
||||
export function contentBounds(
|
||||
img: PixelImage,
|
||||
alphaThreshold = 0
|
||||
): { x: number; y: number; w: number; h: number } | null {
|
||||
let minX = img.width;
|
||||
let minY = img.height;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
if (img.data[(y * img.width + x) * 4 + 3] > alphaThreshold) {
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return null;
|
||||
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
/** Обрезка прозрачных полей по порогу альфы. Полностью пустое → 1×1 прозрачный пиксель. */
|
||||
export function trimToContent(img: PixelImage, alphaThreshold = 0): PixelImage {
|
||||
const b = contentBounds(img, alphaThreshold);
|
||||
if (!b) return crop(img, 0, 0, 1, 1);
|
||||
return crop(img, b.x, b.y, b.w, b.h);
|
||||
}
|
||||
|
||||
/** Приводит холст к точному размеру: лишнее обрезается, недостающее дополняется прозрачным. */
|
||||
export function changeCanvasSize(
|
||||
img: PixelImage,
|
||||
width: number,
|
||||
height: number,
|
||||
anchor: Anchor9
|
||||
): PixelImage {
|
||||
const out = createPixelImage(width, height);
|
||||
const pasteX = anchor.endsWith('-left') ? 0 : anchor.endsWith('-right') ? width - img.width : Math.floor((width - img.width) / 2);
|
||||
const pasteY = anchor.startsWith('top-') ? 0 : anchor.startsWith('bottom-') ? height - img.height : Math.floor((height - img.height) / 2);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const sy = y - pasteY;
|
||||
if (sy < 0 || sy >= img.height) continue;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const sx = x - pasteX;
|
||||
if (sx < 0 || sx >= img.width) continue;
|
||||
const di = (y * width + x) * 4;
|
||||
const si = (sy * img.width + sx) * 4;
|
||||
out.data[di] = img.data[si];
|
||||
out.data[di + 1] = img.data[si + 1];
|
||||
out.data[di + 2] = img.data[si + 2];
|
||||
out.data[di + 3] = img.data[si + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Центральный кроп до соотношения сторон (ratio ≥ 1 = широкое). */
|
||||
export function cropToRatio(img: PixelImage, ratio: number): PixelImage {
|
||||
const current = img.width / img.height;
|
||||
if (current > ratio) {
|
||||
const w = Math.max(1, Math.round(img.height * ratio));
|
||||
return crop(img, Math.floor((img.width - w) / 2), 0, w, img.height);
|
||||
}
|
||||
if (current < ratio) {
|
||||
const h = Math.max(1, Math.round(img.width / ratio));
|
||||
return crop(img, 0, Math.floor((img.height - h) / 2), img.width, h);
|
||||
}
|
||||
return clonePixelImage(img);
|
||||
}
|
||||
|
||||
/** Вписывает в соотношение сторон, добавляя прозрачные поля. */
|
||||
export function padToRatio(img: PixelImage, ratio: number): PixelImage {
|
||||
const current = img.width / img.height;
|
||||
let w = img.width;
|
||||
let h = img.height;
|
||||
if (current > ratio) h = Math.round(w / ratio);
|
||||
else if (current < ratio) w = Math.round(h * ratio);
|
||||
w = Math.max(1, w);
|
||||
h = Math.max(1, h);
|
||||
return changeCanvasSize(img, w, h, 'center');
|
||||
}
|
||||
|
||||
/** Разворачивает изображение на 90°, если его ориентация не совпадает с целевой. Квадрат не трогает. */
|
||||
export function forceOrientation(img: PixelImage, target: 'portrait' | 'landscape'): PixelImage {
|
||||
const current = img.width > img.height ? 'landscape' : img.width < img.height ? 'portrait' : 'square';
|
||||
if (current === target || current === 'square') return clonePixelImage(img);
|
||||
return rotate90(img, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Симметричная копия: к выбранной стороне оригинала добавляется его зеркало.
|
||||
* axis vertical — зеркалим по вертикальной линии (ширина ×2), horizontal — по горизонтальной (высота ×2).
|
||||
*/
|
||||
export function symmetricCopy(
|
||||
img: PixelImage,
|
||||
axis: 'vertical' | 'horizontal',
|
||||
keepSide: 'left' | 'right' | 'top' | 'bottom'
|
||||
): PixelImage {
|
||||
if (axis === 'vertical') {
|
||||
const out = createPixelImage(img.width * 2, img.height);
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const srcX = keepSide === 'left' ? x : img.width - 1 - x;
|
||||
const di = (y * out.width + x) * 4;
|
||||
const si = (y * img.width + srcX) * 4;
|
||||
out.data[di] = img.data[si];
|
||||
out.data[di + 1] = img.data[si + 1];
|
||||
out.data[di + 2] = img.data[si + 2];
|
||||
out.data[di + 3] = img.data[si + 3];
|
||||
const mx = img.width * 2 - 1 - x;
|
||||
const md = (y * out.width + mx) * 4;
|
||||
out.data[md] = img.data[si];
|
||||
out.data[md + 1] = img.data[si + 1];
|
||||
out.data[md + 2] = img.data[si + 2];
|
||||
out.data[md + 3] = img.data[si + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const out = createPixelImage(img.width, img.height * 2);
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const srcY = keepSide === 'top' ? y : img.height - 1 - y;
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const si = (srcY * img.width + x) * 4;
|
||||
const dTop = (y * out.width + x) * 4;
|
||||
out.data[dTop] = img.data[si];
|
||||
out.data[dTop + 1] = img.data[si + 1];
|
||||
out.data[dTop + 2] = img.data[si + 2];
|
||||
out.data[dTop + 3] = img.data[si + 3];
|
||||
const my = img.height * 2 - 1 - y;
|
||||
const md = (my * out.width + x) * 4;
|
||||
out.data[md] = img.data[si];
|
||||
out.data[md + 1] = img.data[si + 1];
|
||||
out.data[md + 2] = img.data[si + 2];
|
||||
out.data[md + 3] = img.data[si + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractByColor,
|
||||
isGrayscaleish,
|
||||
luma01,
|
||||
rarityPredicate,
|
||||
renderPredicateMask
|
||||
} from './masks';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const px = makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[10, 10, 10, 255]
|
||||
]);
|
||||
|
||||
describe('isGrayscaleish / luma01', () => {
|
||||
it('серый распознаётся с допуском, цветной — нет', () => {
|
||||
expect(isGrayscaleish(10, 10, 10, 0)).toBe(true);
|
||||
expect(isGrayscaleish(12, 10, 11, 2)).toBe(true);
|
||||
expect(isGrayscaleish(255, 0, 0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('luma01: белый 1, чёрный 0', () => {
|
||||
expect(luma01(255, 255, 255)).toBeCloseTo(1);
|
||||
expect(luma01(0, 0, 0)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderPredicateMask', () => {
|
||||
it('binary: совпавшие белые на чёрном, альфа принудительная', () => {
|
||||
const out = renderPredicateMask(
|
||||
px,
|
||||
(r) => r > 200,
|
||||
{ mode: 'binary' }
|
||||
);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 255]);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([0, 0, 0, 255]);
|
||||
});
|
||||
|
||||
it('highlight: подкрашивает только совпавшие, остальные без изменений', () => {
|
||||
const out = renderPredicateMask(
|
||||
px,
|
||||
(r) => r > 200,
|
||||
{ mode: 'highlight', color: '#0000ff', opacityPercent: 100 }
|
||||
);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 255, 255]);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([10, 10, 10, 255]);
|
||||
});
|
||||
|
||||
it('прозрачность оригинала сохраняется в highlight-режиме', () => {
|
||||
const semi = makeImage(1, 1, [[200, 200, 200, 128]]);
|
||||
const out = renderPredicateMask(semi, () => true, {
|
||||
mode: 'highlight',
|
||||
color: '#00ff00',
|
||||
opacityPercent: 50
|
||||
});
|
||||
expect(out.data[3]).toBe(128);
|
||||
expect(out.data[0]).toBeGreaterThan(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rarityPredicate + extractByColor', () => {
|
||||
const img = makeImage(3, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255]
|
||||
]);
|
||||
|
||||
it('уникальный (единичный) цвет находится, массовый — нет', () => {
|
||||
const pred = rarityPredicate(img, 1);
|
||||
expect(pred(0, 255, 0, 255)).toBe(true);
|
||||
expect(pred(255, 0, 0, 255)).toBe(false);
|
||||
});
|
||||
|
||||
it('extractByColor оставляет близкие и делает дальние прозрачными', () => {
|
||||
const out = extractByColor(px, '#0a0a0a', 5);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect(out.data[7]).toBe(255);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { hexToRgb } from './palette';
|
||||
import type { PixelImage } from './types';
|
||||
import { createPixelImage } from './types';
|
||||
|
||||
export type MaskMode = 'binary' | 'highlight';
|
||||
|
||||
export interface MaskOptions {
|
||||
/** binary: белое/чёрное без альфы; highlight: подкрасить совпавшие пиксели цветом. */
|
||||
mode?: MaskMode;
|
||||
color?: string;
|
||||
opacityPercent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый рендер масок: предикат решает, совпал ли пиксель;
|
||||
* режим определяет вид результата.
|
||||
*/
|
||||
export function renderPredicateMask(
|
||||
img: PixelImage,
|
||||
predicate: (r: number, g: number, b: number, a: number) => boolean,
|
||||
o: MaskOptions = {}
|
||||
): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const highlight = o.mode !== 'binary';
|
||||
const tint = o.color ? hexToRgb(o.color) : { r: 255, g: 0, b: 170 };
|
||||
const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100;
|
||||
if (!highlight) {
|
||||
// Бинарная маска — непрозрачное белое-на-чёрном
|
||||
for (let a = 3; a < out.data.length; a += 4) out.data[a] = 255;
|
||||
}
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const r = img.data[i];
|
||||
const g = img.data[i + 1];
|
||||
const b = img.data[i + 2];
|
||||
const a = img.data[i + 3];
|
||||
if (!predicate(r, g, b, a)) {
|
||||
if (highlight && o.mode === 'highlight') {
|
||||
out.data[i] = r;
|
||||
out.data[i + 1] = g;
|
||||
out.data[i + 2] = b;
|
||||
out.data[i + 3] = a;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!highlight) {
|
||||
out.data[i] = 255;
|
||||
out.data[i + 1] = 255;
|
||||
out.data[i + 2] = 255;
|
||||
out.data[i + 3] = 255;
|
||||
continue;
|
||||
}
|
||||
out.data[i] = Math.round(r * (1 - opacity) + tint.r * opacity);
|
||||
out.data[i + 1] = Math.round(g * (1 - opacity) + tint.g * opacity);
|
||||
out.data[i + 2] = Math.round(b * (1 - opacity) + tint.b * opacity);
|
||||
out.data[i + 3] = a;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function maxChannelDelta(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
|
||||
return Math.max(Math.abs(r1 - r2), Math.abs(g1 - g2), Math.abs(b1 - b2));
|
||||
}
|
||||
|
||||
export function isGrayscaleish(r: number, g: number, b: number, tolerance: number): boolean {
|
||||
return (
|
||||
Math.abs(r - g) <= tolerance && Math.abs(g - b) <= tolerance && Math.abs(r - b) <= tolerance
|
||||
);
|
||||
}
|
||||
|
||||
export function luma01(r: number, g: number, b: number): number {
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* Оставляет пиксели, близкие к целевому цвету, остальное делает прозрачным.
|
||||
* Допуск — в процентах от максимального поканального разброса (255).
|
||||
*/
|
||||
export function extractByColor(
|
||||
img: PixelImage,
|
||||
targetHex: string,
|
||||
tolerancePercent: number
|
||||
): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const t = hexToRgb(targetHex);
|
||||
const tol = (Math.min(Math.max(tolerancePercent, 0), 100) / 100) * 255;
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
if (maxChannelDelta(img.data[i], img.data[i + 1], img.data[i + 2], t.r, t.g, t.b) <= tol) {
|
||||
out.data[i] = img.data[i];
|
||||
out.data[i + 1] = img.data[i + 1];
|
||||
out.data[i + 2] = img.data[i + 2];
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Считает частоты цветов и возвращает предикат «встречается не чаще limit раз».
|
||||
*/
|
||||
export function rarityPredicate(
|
||||
img: PixelImage,
|
||||
limit: number
|
||||
): (r: number, g: number, b: number, a: number) => boolean {
|
||||
const counts = new Map<number, number>();
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const key = (img.data[i] << 16) | (img.data[i + 1] << 8) | img.data[i + 2];
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return (r, g, b) => {
|
||||
const key = (r << 16) | (g << 8) | b;
|
||||
return (counts.get(key) ?? 0) <= limit;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
analogousSet,
|
||||
complementarySet,
|
||||
hexToRgb,
|
||||
hslToRgb,
|
||||
monochromaticSet,
|
||||
mixColors,
|
||||
normalizeHex,
|
||||
parseHexList,
|
||||
renderBlend,
|
||||
renderSwatches,
|
||||
renderWheel,
|
||||
rgbToHsl,
|
||||
rgbToHex,
|
||||
shadeSet,
|
||||
sortPalette,
|
||||
triadicSet,
|
||||
tetradicSet
|
||||
} from './palette';
|
||||
|
||||
describe('конвертация rgb↔hsl', () => {
|
||||
it('красный → hsl(0,100%,50%) и обратно', () => {
|
||||
const hsl = rgbToHsl(hexToRgb('#ff0000'));
|
||||
expect(hsl.h).toBeCloseTo(0, 0);
|
||||
expect(hsl.s).toBeCloseTo(1, 5);
|
||||
expect(hsl.l).toBeCloseTo(0.5, 5);
|
||||
expect(rgbToHex(hslToRgb({ h: 0, s: 1, l: 0.5 }))).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('серые не имеют оттенка', () => {
|
||||
expect(rgbToHsl(hexToRgb('#808080')).s).toBe(0);
|
||||
});
|
||||
|
||||
it('круговой переход 360° возвращает исходный цвет', () => {
|
||||
const base = '#3b82f6';
|
||||
expect(rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 }))).toBe(
|
||||
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 30 }))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('гармонии', () => {
|
||||
it('complementary — пара с сдвигом 180°', () => {
|
||||
const [a, b] = complementarySet('#ff0000');
|
||||
expect(a).toBe('#ff0000');
|
||||
const ha = rgbToHsl(hexToRgb(a)).h;
|
||||
const hb = rgbToHsl(hexToRgb(b)).h;
|
||||
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
|
||||
});
|
||||
|
||||
it('triadic — три цвета через 120°', () => {
|
||||
const set = triadicSet('#ff0000');
|
||||
expect(set).toHaveLength(3);
|
||||
const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h);
|
||||
expect(hues[1] - hues[0]).toBeCloseTo(120, 0);
|
||||
expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
|
||||
});
|
||||
|
||||
it('tetradic — четыре цвета через 90°', () => {
|
||||
const set = tetradicSet('#00ff00');
|
||||
expect(set).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('analogous симметричен вокруг базы', () => {
|
||||
const set = analogousSet('#0000ff', 30, 5);
|
||||
expect(set).toHaveLength(5);
|
||||
expect(set[2]).toBe(normalizeHex('#0000ff'));
|
||||
});
|
||||
|
||||
it('monochromatic держит оттенок, меняет светлоту', () => {
|
||||
const set = monochromaticSet('#ff8800', 5, 60);
|
||||
expect(set).toHaveLength(5);
|
||||
const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h)));
|
||||
expect(hues.size).toBe(1);
|
||||
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
|
||||
expect(Math.min(...lights)).toBeLessThan(Math.max(...lights));
|
||||
});
|
||||
|
||||
it('shades — тёмный край темнее базы', () => {
|
||||
const set = shadeSet('#88cc44', 4, 70);
|
||||
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
|
||||
expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHexList / mixColors / sortPalette', () => {
|
||||
it('парсит список и отбрасывает мусор и токены без решётки', () => {
|
||||
expect(parseHexList('#ff0000, #00FF00 ; 00ff00 zz')).toEqual(['#ff0000', '#00ff00']);
|
||||
});
|
||||
|
||||
it('пустой валидный список бросает badHex', () => {
|
||||
expect(() => parseHexList('нет цветов')).toThrow(/errors\.badHex/);
|
||||
});
|
||||
|
||||
it('mixColors — среднее компонент', () => {
|
||||
expect(mixColors(['#000000', '#ffffff'])).toBe('#808080');
|
||||
});
|
||||
|
||||
it('sortPalette по luma ставит тёмные раньше', () => {
|
||||
const sorted = sortPalette(['#ffffff', '#000000', '#808080'], 'luma');
|
||||
expect(sorted[0]).toBe('#000000');
|
||||
expect(sorted[2]).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('рендеры', () => {
|
||||
it('renderSwatches strip: колонки по цветам', () => {
|
||||
const img = renderSwatches(['#ff0000', '#00ff00'], 200, 'strip');
|
||||
expect(img.width).toBe(200);
|
||||
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4 + 3]).toBe(255);
|
||||
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4]).toBeGreaterThan(200);
|
||||
const right = (Math.floor(img.height / 2) * 200 + 150) * 4;
|
||||
expect(img.data[right]).toBeLessThan(50);
|
||||
expect(img.data[right + 1]).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it('renderSwatches grid: квадратные ячейки', () => {
|
||||
const img = renderSwatches(['#111111', '#222222', '#333333'], 120, 'grid');
|
||||
expect(img.width).toBe(120);
|
||||
expect(img.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renderWheel: углы прозрачны, центр непрозрачен', () => {
|
||||
const size = 101;
|
||||
const img = renderWheel(size, 50);
|
||||
expect(img.data[3]).toBe(0);
|
||||
const c = ((Math.floor(size / 2) * size) + Math.floor(size / 2)) * 4;
|
||||
expect(img.data[c + 3]).toBe(255);
|
||||
});
|
||||
|
||||
it('renderBlend: левый край = a, правый = b', () => {
|
||||
const img = renderBlend('#000000', '#ffffff', 100);
|
||||
expect(img.data[0]).toBe(0);
|
||||
const last = (99 * 4);
|
||||
expect(img.data[last]).toBe(255);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { ToolError } from './errors';
|
||||
import type { PixelImage } from './types';
|
||||
import { createPixelImage } from './types';
|
||||
|
||||
export type Rgb = { r: number; g: number; b: number };
|
||||
export type Hsl = { h: number; s: number; l: number };
|
||||
|
||||
export function hexToRgb(hex: string): Rgb {
|
||||
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) throw new ToolError('errors.badHex', { value: hex });
|
||||
const d = m[1];
|
||||
return {
|
||||
r: parseInt(d.slice(0, 2), 16),
|
||||
g: parseInt(d.slice(2, 4), 16),
|
||||
b: parseInt(d.slice(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
const byte = (v: number) =>
|
||||
Math.round(Math.min(255, Math.max(0, v)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
|
||||
export function rgbToHex({ r, g, b }: Rgb): string {
|
||||
return `#${byte(r)}${byte(g)}${byte(b)}`;
|
||||
}
|
||||
|
||||
/** h ∈ [0..360), s,l ∈ [0..1] */
|
||||
export function rgbToHsl({ r, g, b }: Rgb): Hsl {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return { h: 0, s: 0, l };
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h: number;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) * 60;
|
||||
else h = ((rn - gn) / d + 4) * 60;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
export function hslToRgb({ h, s, l }: Hsl): Rgb {
|
||||
const hn = ((h % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((hn / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (hn < 60) [r, g, b] = [c, x, 0];
|
||||
else if (hn < 120) [r, g, b] = [x, c, 0];
|
||||
else if (hn < 180) [r, g, b] = [0, c, x];
|
||||
else if (hn < 240) [r, g, b] = [0, x, c];
|
||||
else if (hn < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255)
|
||||
};
|
||||
}
|
||||
|
||||
export function shiftHue(hex: string, deltaDeg: number): string {
|
||||
const hsl = rgbToHsl(hexToRgb(hex));
|
||||
return rgbToHex(hslToRgb({ ...hsl, h: hsl.h + deltaDeg }));
|
||||
}
|
||||
|
||||
function withLightness(hex: string, l: number): string {
|
||||
return rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(hex)), l }));
|
||||
}
|
||||
|
||||
export function complementarySet(base: string): string[] {
|
||||
return [normalizeHex(base), shiftHue(base, 180)];
|
||||
}
|
||||
|
||||
export function triadicSet(base: string): string[] {
|
||||
return [normalizeHex(base), shiftHue(base, 120), shiftHue(base, 240)];
|
||||
}
|
||||
|
||||
export function tetradicSet(base: string): string[] {
|
||||
return [normalizeHex(base), shiftHue(base, 90), shiftHue(base, 180), shiftHue(base, 270)];
|
||||
}
|
||||
|
||||
export function analogousSet(base: string, spreadDeg: number, count: number): string[] {
|
||||
const n = Math.max(3, Math.min(9, Math.round(count)));
|
||||
const half = Math.floor(n / 2);
|
||||
return Array.from({ length: n }, (_, i) => shiftHue(base, (i - half) * spreadDeg));
|
||||
}
|
||||
|
||||
export function monochromaticSet(base: string, count: number, rangePercent: number): string[] {
|
||||
const n = Math.max(2, Math.min(9, Math.round(count)));
|
||||
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
|
||||
const halfSpan = Math.min(0.495, rangePercent / 200);
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const t = n === 1 ? 0.5 : i / (n - 1);
|
||||
const l = clamp01(baseL - halfSpan + t * halfSpan * 2);
|
||||
return withLightness(normalizeHex(base), l);
|
||||
});
|
||||
}
|
||||
|
||||
export function shadeSet(base: string, count: number, depthPercent: number): string[] {
|
||||
const n = Math.max(2, Math.min(9, Math.round(count)));
|
||||
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
|
||||
const floorL = Math.max(0.03, baseL - depthPercent / 100);
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const t = i / (n - 1);
|
||||
return withLightness(normalizeHex(base), baseL - (baseL - floorL) * t);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseHexList(text: string): string[] {
|
||||
const list = text
|
||||
.split(/[,\s;]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^#[0-9a-f]{6}$/i.test(t))
|
||||
.map((t) => normalizeHex(t));
|
||||
if (list.length === 0) throw new ToolError('errors.badHex', { value: text });
|
||||
return list;
|
||||
}
|
||||
|
||||
export function normalizeHex(hex: string): string {
|
||||
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) throw new ToolError('errors.badHex', { value: hex });
|
||||
return `#${m[1].toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function mixColors(hexes: string[]): string {
|
||||
const sum = hexes
|
||||
.map(hexToRgb)
|
||||
.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), { r: 0, g: 0, b: 0 });
|
||||
return rgbToHex({
|
||||
r: sum.r / hexes.length,
|
||||
g: sum.g / hexes.length,
|
||||
b: sum.b / hexes.length
|
||||
});
|
||||
}
|
||||
|
||||
export function luma(hex: string): number {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
export type SortKey = 'hue' | 'luma' | 'sat';
|
||||
|
||||
export function sortPalette(hexes: string[], key: SortKey): string[] {
|
||||
const scored = hexes.map((h) => {
|
||||
if (key === 'luma') return { h, k: luma(h) };
|
||||
const hsl = rgbToHsl(hexToRgb(h));
|
||||
return { h, k: key === 'hue' ? hsl.h : hsl.s };
|
||||
});
|
||||
return scored.sort((a, b) => a.k - b.k || a.h.localeCompare(b.h)).map((s) => s.h);
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return Math.min(1, Math.max(0, v));
|
||||
}
|
||||
|
||||
/** Горизонтальные равные колонки-свотчи (strip) или сетка ~квадратных ячеек (grid). */
|
||||
export function renderSwatches(colors: string[], width: number, layout: 'strip' | 'grid'): PixelImage {
|
||||
const n = colors.length;
|
||||
if (layout === 'strip') {
|
||||
const cellW = width / n;
|
||||
const height = Math.max(24, Math.round(cellW));
|
||||
const out = createPixelImage(width, height);
|
||||
colors.forEach((hex, i) => {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
fillRect(out, Math.floor(i * cellW), 0, Math.ceil(cellW), height, r, g, b);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(n)));
|
||||
const rows = Math.max(1, Math.ceil(n / cols));
|
||||
const cell = Math.floor(width / cols);
|
||||
const height = cell * rows;
|
||||
const out = createPixelImage(width, height);
|
||||
colors.forEach((hex, i) => {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
fillRect(out, (i % cols) * cell, Math.floor(i / cols) * cell, cell, cell, r, g, b);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderWheel(size: number, lightness: number): PixelImage {
|
||||
const out = createPixelImage(size, size);
|
||||
const c = (size - 1) / 2;
|
||||
const radius = c;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x - c;
|
||||
const dy = y - c;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const di = (y * size + x) * 4;
|
||||
if (dist > radius) continue;
|
||||
const h = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||
const s = dist / radius;
|
||||
const { r, g, b } = hslToRgb({ h, s, l: lightness / 100 });
|
||||
out.data[di] = r;
|
||||
out.data[di + 1] = g;
|
||||
out.data[di + 2] = b;
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderBlend(a: string, b: string, width: number): PixelImage {
|
||||
const height = Math.max(24, Math.round(width / 4));
|
||||
const out = createPixelImage(width, height);
|
||||
const ca = hexToRgb(a);
|
||||
const cb = hexToRgb(b);
|
||||
for (let x = 0; x < width; x++) {
|
||||
const t = width === 1 ? 0 : x / (width - 1);
|
||||
fillRect(
|
||||
out,
|
||||
x,
|
||||
0,
|
||||
1,
|
||||
height,
|
||||
Math.round(ca.r + (cb.r - ca.r) * t),
|
||||
Math.round(ca.g + (cb.g - ca.g) * t),
|
||||
Math.round(ca.b + (cb.b - ca.b) * t)
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function stepColors(aHex: string, bHex: string, steps: number): string[] {
|
||||
const n = Math.max(2, Math.min(12, Math.round(steps)));
|
||||
const ca = hexToRgb(aHex);
|
||||
const cb = hexToRgb(bHex);
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const t = i / (n - 1);
|
||||
return rgbToHex({
|
||||
r: ca.r + (cb.r - ca.r) * t,
|
||||
g: ca.g + (cb.g - ca.g) * t,
|
||||
b: ca.b + (cb.b - ca.b) * t
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillRect(
|
||||
img: PixelImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
): void {
|
||||
for (let y = y0; y < Math.min(y0 + h, img.height); y++) {
|
||||
for (let x = x0; x < Math.min(x0 + w, img.width); x++) {
|
||||
const di = (y * img.width + x) * 4;
|
||||
img.data[di] = r;
|
||||
img.data[di + 1] = g;
|
||||
img.data[di + 2] = b;
|
||||
img.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { addNoise, defringe, featherAlpha, mulberry32, pixelate, shuffleBlocks, silhouette } from './pixel-fx';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('pixelate', () => {
|
||||
it('блок усредняется: шахматка 2×2 с блоком 2 → один цвет', () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[0, 0, 0, 255],
|
||||
[200, 200, 200, 255],
|
||||
[100, 100, 100, 255],
|
||||
[140, 140, 140, 255]
|
||||
]);
|
||||
const out = pixelate(img, 2);
|
||||
expect(out.data[0]).toBeCloseTo(110, 0);
|
||||
expect(out.data[4]).toBeCloseTo(110, 0);
|
||||
});
|
||||
|
||||
it('однотонное изображение не меняется', () => {
|
||||
const img = makeImage(3, 3, new Array(9).fill([50, 60, 70, 255]));
|
||||
expect([...pixelate(img, 2).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffleBlocks / addNoise — детерминизм по seed', () => {
|
||||
it('тот же seed даёт то же перемешивание', () => {
|
||||
const img = makeImage(4, 1, [
|
||||
[10, 0, 0, 255],
|
||||
[20, 0, 0, 255],
|
||||
[30, 0, 0, 255],
|
||||
[40, 0, 0, 255]
|
||||
]);
|
||||
const a = shuffleBlocks(img, 1, 7);
|
||||
const b = shuffleBlocks(img, 1, 7);
|
||||
expect([...a.data]).toEqual([...b.data]);
|
||||
});
|
||||
|
||||
it('мультимножество пикселей сохраняется (перестановка)', () => {
|
||||
const img = makeImage(4, 1, [
|
||||
[10, 0, 0, 255],
|
||||
[20, 0, 0, 255],
|
||||
[30, 0, 0, 255],
|
||||
[40, 0, 0, 255]
|
||||
]);
|
||||
const out = [...shuffleBlocks(img, 1, 99).data.filter((_, i) => i % 4 === 0)].sort(
|
||||
(x, y) => x - y
|
||||
);
|
||||
expect(out).toEqual([10, 20, 30, 40]);
|
||||
});
|
||||
|
||||
it('addNoise при том же seed воспроизводим, amount=0 — идентичность', () => {
|
||||
const img = makeImage(2, 2, new Array(4).fill([128, 64, 32, 255]));
|
||||
const a = addNoise(img, 20, 'mono', 5);
|
||||
const b = addNoise(img, 20, 'mono', 5);
|
||||
expect([...a.data]).toEqual([...b.data]);
|
||||
expect([...addNoise(img, 0, 'mono', 999).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('featherAlpha', () => {
|
||||
it('жёсткий край получает промежуточные альфы', () => {
|
||||
// левая половина непрозрачная, правая прозрачная
|
||||
const img = makeImage(6, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 0],
|
||||
[255, 0, 0, 0],
|
||||
[255, 0, 0, 0]
|
||||
]);
|
||||
const out = featherAlpha(img, 1);
|
||||
const alphas = [0, 1, 2].map((x) => out.data[x * 4 + 3]);
|
||||
expect(alphas[0]).toBeGreaterThan(240);
|
||||
expect(alphas.some((v) => v > 0 && v < 240)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defringe', () => {
|
||||
it('полупрозрачному пикселю берётся RGB от соседнего непрозрачного', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[250, 250, 250, 255],
|
||||
[255, 0, 0, 128]
|
||||
]);
|
||||
const out = defringe(img, 2);
|
||||
expect(out.data[4]).toBe(250);
|
||||
expect(out.data[6]).toBe(250);
|
||||
expect(out.data[7]).toBe(128); // альфа сохранена
|
||||
});
|
||||
|
||||
it('полностью прозрачные области не трогаются', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[250, 250, 250, 255],
|
||||
[0, 0, 0, 0]
|
||||
]);
|
||||
const out = defringe(img, 2);
|
||||
expect(out.data[4 + 3]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('silhouette', () => {
|
||||
it('видимые пиксели заливаются цветом, ниже порога — прозрачность', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[123, 45, 67, 255],
|
||||
[123, 45, 67, 10]
|
||||
]);
|
||||
const out = silhouette(img, '#00ff00', 50);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([0, 255, 0, 255]);
|
||||
expect(out.data[4 + 3]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mulberry32', () => {
|
||||
it('последовательность детерминирована', () => {
|
||||
const a = mulberry32(42);
|
||||
const b = mulberry32(42);
|
||||
expect([a(), a(), a()]).toEqual([b(), b(), b()]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { hexToRgb } from './palette';
|
||||
import type { PixelImage } from './types';
|
||||
import { createPixelImage } from './types';
|
||||
import { gaussianBlur } from './convolution';
|
||||
|
||||
/** Детерминированный ГПСЧ (mulberry32): одинаковый seed — одинаковый результат. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(v: number): number {
|
||||
return v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
|
||||
}
|
||||
|
||||
/** Усреднение каждого блока blockSize×BlockSize в его верхний-левый пиксель цвета. */
|
||||
export function pixelate(img: PixelImage, blockSize: number): PixelImage {
|
||||
const bs = Math.max(1, Math.round(blockSize));
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let by = 0; by < img.height; by += bs) {
|
||||
for (let bx = 0; bx < img.width; bx += bs) {
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
let n = 0;
|
||||
const yMax = Math.min(by + bs, img.height);
|
||||
const xMax = Math.min(bx + bs, img.width);
|
||||
for (let y = by; y < yMax; y++) {
|
||||
for (let x = bx; x < xMax; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
r += img.data[i];
|
||||
g += img.data[i + 1];
|
||||
b += img.data[i + 2];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n === 0) continue;
|
||||
r /= n;
|
||||
g /= n;
|
||||
b /= n;
|
||||
for (let y = by; y < yMax; y++) {
|
||||
for (let x = bx; x < xMax; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
out.data[i] = r;
|
||||
out.data[i + 1] = g;
|
||||
out.data[i + 2] = b;
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Перемешивает блоки blockSize×Blocksize между собой детерминированно по seed. */
|
||||
export function shuffleBlocks(img: PixelImage, blockSize: number, seed: number): PixelImage {
|
||||
const bs = Math.max(1, Math.round(blockSize));
|
||||
const cols = Math.ceil(img.width / bs);
|
||||
const rows = Math.ceil(img.height / bs);
|
||||
const total = cols * rows;
|
||||
|
||||
const rng = mulberry32(seed);
|
||||
const order = Array.from({ length: total }, (_, i) => i);
|
||||
for (let i = total - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
}
|
||||
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < total; i++) {
|
||||
const src = order[i];
|
||||
const sx = (i % cols) * bs;
|
||||
const sy = Math.floor(i / cols) * bs;
|
||||
const dx = (src % cols) * bs;
|
||||
const dy = Math.floor(src / cols) * bs;
|
||||
for (let yy = 0; yy < bs; yy++) {
|
||||
for (let xx = 0; xx < bs; xx++) {
|
||||
if (sy + yy >= img.height || sx + xx >= img.width) continue;
|
||||
if (dy + yy >= img.height || dx + xx >= img.width) continue;
|
||||
const si = ((sy + yy) * img.width + sx + xx) * 4;
|
||||
const di = ((dy + yy) * img.width + dx + xx) * 4;
|
||||
for (let ch = 0; ch < 4; ch++) out.data[di + ch] = img.data[si + ch];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type NoiseMode = 'mono' | 'color';
|
||||
|
||||
/** Зерно: amountPercent — сила отклонения от оригинала. Детерминировано по seed. */
|
||||
export function addNoise(
|
||||
img: PixelImage,
|
||||
amountPercent: number,
|
||||
mode: NoiseMode,
|
||||
seed: number
|
||||
): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const amount = Math.min(Math.max(amountPercent, 0), 100) / 100;
|
||||
const rng = mulberry32(seed);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const shift = (rng() * 2 - 1) * amount * 255;
|
||||
if (mode === 'mono') {
|
||||
out.data[i] = clampByte(img.data[i] + shift);
|
||||
out.data[i + 1] = clampByte(img.data[i + 1] + shift);
|
||||
out.data[i + 2] = clampByte(img.data[i + 2] + shift);
|
||||
} else {
|
||||
out.data[i] = clampByte(img.data[i] + (rng() * 2 - 1) * amount * 255);
|
||||
out.data[i + 1] = clampByte(img.data[i + 1] + (rng() * 2 - 1) * amount * 255);
|
||||
out.data[i + 2] = clampByte(img.data[i + 2] + (rng() * 2 - 1) * amount * 255);
|
||||
}
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Размытие только альфа-канала: мягкие края при неизменном цвете. */
|
||||
export function featherAlpha(img: PixelImage, radius: number): PixelImage {
|
||||
const gray = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
gray.data[i] = img.data[i + 3];
|
||||
gray.data[i + 1] = img.data[i + 3];
|
||||
gray.data[i + 2] = img.data[i + 3];
|
||||
gray.data[i + 3] = 255;
|
||||
}
|
||||
const blurred = gaussianBlur(gray, radius);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
out.data[i] = img.data[i];
|
||||
out.data[i + 1] = img.data[i + 1];
|
||||
out.data[i + 2] = img.data[i + 2];
|
||||
out.data[i + 3] = blurred.data[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает цветную кайму на полупрозрачных краях: RGB полупрозрачного пикселя
|
||||
* заменяется цветом ближайшего полностью непрозрачного соседа в пределах radius.
|
||||
*/
|
||||
export function defringe(img: PixelImage, radius: number): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
out.data.set(img.data);
|
||||
const rad = Math.max(1, Math.round(radius));
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const di = (y * img.width + x) * 4;
|
||||
const a = img.data[di + 3];
|
||||
if (a === 0 || a === 255) continue;
|
||||
let found = false;
|
||||
for (let ry = 1; ry <= rad && !found; ry++) {
|
||||
for (let dy = -ry; dy <= ry && !found; dy++) {
|
||||
for (let dx = -ry; dx <= ry && !found; dx++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ry) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= img.width || ny >= img.height) continue;
|
||||
const si = (ny * img.width + nx) * 4;
|
||||
if (img.data[si + 3] !== 255) continue;
|
||||
out.data[di] = img.data[si];
|
||||
out.data[di + 1] = img.data[si + 1];
|
||||
out.data[di + 2] = img.data[si + 2];
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Силуэт: все видимые пиксели заливаются одним цветом, альфа сохраняется. */
|
||||
export function silhouette(img: PixelImage, colorHex: string, alphaThreshold: number): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const { r, g, b } = hexToRgb(colorHex);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
if (img.data[i + 3] <= alphaThreshold) continue;
|
||||
out.data[i] = r;
|
||||
out.data[i + 1] = g;
|
||||
out.data[i + 2] = b;
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { boxTest, circleTest, renderShape, starTest, wavyTest } from './shapes';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('тесты фигур', () => {
|
||||
it('круг: центр внутри, угол снаружи', () => {
|
||||
const t = circleTest(0.5);
|
||||
expect(t(0, 0)).toBe(true);
|
||||
expect(t(0.49, 0)).toBe(true);
|
||||
expect(t(0.51, 0)).toBe(false);
|
||||
expect(t(0.4, 0.4)).toBe(false);
|
||||
});
|
||||
|
||||
it('прямоугольник: полуоси независимы', () => {
|
||||
const t = boxTest(0.5, 0.25);
|
||||
expect(t(0.45, 0.2)).toBe(true);
|
||||
expect(t(0.2, 0.3)).toBe(false);
|
||||
});
|
||||
|
||||
it('звезда: луч внутри дальше впадины', () => {
|
||||
const t = starTest(5, 0.5, 1, 0);
|
||||
expect(t(0.9, 0)).toBe(true); // вдоль луча (θ=0)
|
||||
const valleyAngle = Math.PI / 5; // середина между лучами
|
||||
expect(t(Math.cos(valleyAngle) * 0.8, Math.sin(valleyAngle) * 0.8)).toBe(false);
|
||||
expect(t(0.4, 0)).toBe(true); // радиус впадин 0.5 — 0.4 внутри всегда
|
||||
});
|
||||
|
||||
it('волна: фаза двигает границу', () => {
|
||||
const a = wavyTest(0.5, 0.1, 6, 0);
|
||||
const b = wavyTest(0.5, 0.1, 6, 180);
|
||||
const deg = 15; // sin(6·15°)=1 → край 0.6; при фазе 180° край 0.4
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const px = 0.55;
|
||||
expect(a(px * Math.cos(rad), px * Math.sin(rad))).toBe(true); // край 0.6
|
||||
expect(b(px * Math.cos(rad), px * Math.sin(rad))).toBe(false); // край 0.4
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderShape', () => {
|
||||
const img = makeImage(4, 2, new Array(8).fill([255, 255, 255, 255]));
|
||||
|
||||
it('внутри сохраняет пиксели, снаружи альфа 0', () => {
|
||||
const out = renderShape(img, boxTest(0.5, 0.5));
|
||||
let opaque = 0;
|
||||
for (let i = 3; i < out.data.length; i += 4) if (out.data[i] === 255) opaque++;
|
||||
expect(opaque).toBe(4);
|
||||
expect(out.data[4]).toBe(255); // RGB внутри фигуры сохранён
|
||||
});
|
||||
|
||||
it('смещение центра переносит маску', () => {
|
||||
const shifted = renderShape(makeImage(2, 2, new Array(4).fill([255, 255, 255, 255])), circleTest(0.7), 0.5);
|
||||
expect(shifted.data[3]).toBe(0);
|
||||
expect(shifted.data[4 + 3]).toBe(255);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PixelImage } from './types';
|
||||
import { createPixelImage } from './types';
|
||||
|
||||
export type ShapeTest = (nx: number, ny: number) => boolean;
|
||||
|
||||
/**
|
||||
* Координаты теста: нормированные к половине меньшей стороны изображения,
|
||||
* центр в (0,0), ось Y вниз как в пикселях.
|
||||
*/
|
||||
|
||||
export function circleTest(radiusFrac: number): ShapeTest {
|
||||
return (nx, ny) => Math.hypot(nx, ny) <= radiusFrac;
|
||||
}
|
||||
|
||||
export function boxTest(halfWFrac: number, halfHFrac: number): ShapeTest {
|
||||
return (nx, ny) => Math.abs(nx) <= halfWFrac && Math.abs(ny) <= halfHFrac;
|
||||
}
|
||||
|
||||
/** Звезда с points лучами; innerFrac — радиус впадин в долях внешнего радиуса. */
|
||||
export function starTest(
|
||||
points: number,
|
||||
innerFrac: number,
|
||||
outerFrac: number,
|
||||
rotationDeg: number
|
||||
): ShapeTest {
|
||||
const n = Math.max(3, Math.round(points));
|
||||
const rot = (rotationDeg * Math.PI) / 180;
|
||||
return (nx, ny) => {
|
||||
const theta = Math.atan2(ny, nx) - rot;
|
||||
const r = Math.hypot(nx, ny);
|
||||
if (r > outerFrac) return false;
|
||||
const t = ((theta * n) / (2 * Math.PI)) % 1;
|
||||
const tri = Math.abs(t - Math.floor(t + 0.5)) * 2; // 0 на луче, 1 во впадине
|
||||
const edge = outerFrac - (outerFrac - innerFrac) * tri;
|
||||
return r <= edge;
|
||||
};
|
||||
}
|
||||
|
||||
/** Волнистый круг: радиус модулируется синусом с частотой waves. */
|
||||
export function wavyTest(
|
||||
baseFrac: number,
|
||||
amplitudeFrac: number,
|
||||
waves: number,
|
||||
phaseDeg: number
|
||||
): ShapeTest {
|
||||
const phase = (phaseDeg * Math.PI) / 180;
|
||||
return (nx, ny) => {
|
||||
const theta = Math.atan2(ny, nx);
|
||||
const r = Math.hypot(nx, ny);
|
||||
const edge = baseFrac + amplitudeFrac * Math.sin(waves * theta + phase);
|
||||
return r <= edge;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Вырезает фигуру из изображения: внутри фигуры сохраняются исходные пиксели
|
||||
* (с их альфой), снаружи альфа обнуляется. Смещение задаётся в долях меньшей стороны.
|
||||
*/
|
||||
export function renderShape(
|
||||
img: PixelImage,
|
||||
test: ShapeTest,
|
||||
offsetXFrac = 0,
|
||||
offsetYFrac = 0
|
||||
): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const minDim = Math.min(img.width, img.height);
|
||||
const cx = img.width / 2 + offsetXFrac * minDim;
|
||||
const cy = img.height / 2 + offsetYFrac * minDim;
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const di = (y * img.width + x) * 4;
|
||||
if (!test((x + 0.5 - cx) / minDim, (y + 0.5 - cy) / minDim)) continue;
|
||||
out.data[di] = img.data[di];
|
||||
out.data[di + 1] = img.data[di + 1];
|
||||
out.data[di + 2] = img.data[di + 2];
|
||||
out.data[di + 3] = img.data[di + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { anchorOrigin, tileGrid, wrapText } from './textdraw';
|
||||
|
||||
const measure = (s: string) => s.length * 10;
|
||||
|
||||
describe('anchorOrigin', () => {
|
||||
it('углы и отступ считаются от краёв', () => {
|
||||
expect(anchorOrigin('top-left', 100, 50, 400, 300, 30)).toEqual({ x: 30, y: 30 });
|
||||
expect(anchorOrigin('top-right', 100, 50, 400, 300, 30)).toEqual({ x: 270, y: 30 });
|
||||
expect(anchorOrigin('bottom-left', 100, 50, 400, 300, 30)).toEqual({ x: 30, y: 220 });
|
||||
expect(anchorOrigin('bottom-right', 100, 50, 400, 300, 30)).toEqual({ x: 270, y: 220 });
|
||||
});
|
||||
|
||||
it('центрирование — ровно половина остатка', () => {
|
||||
expect(anchorOrigin('center', 100, 50, 401, 301, 0)).toEqual({ x: 150.5, y: 125.5 });
|
||||
expect(anchorOrigin('middle-left', 100, 50, 400, 300, 12)).toEqual({ x: 12, y: 125 });
|
||||
expect(anchorOrigin('top-center', 100, 50, 400, 300, 8)).toEqual({ x: 150, y: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapText', () => {
|
||||
it('жадно набирает строки в пределах ширины', () => {
|
||||
// measure: 10px за символ → строка ≤ 120px = 12 символов
|
||||
expect(wrapText('один два три четыре пять', 120, measure)).toEqual([
|
||||
'один два три',
|
||||
'четыре пять'
|
||||
]);
|
||||
});
|
||||
|
||||
it('слово длиннее ширины уходит на отдельную строку целиком', () => {
|
||||
expect(wrapText('короткое сверхдлинноеслово без переносов', 90, measure)).toEqual([
|
||||
'короткое',
|
||||
'сверхдлинноеслово',
|
||||
'без',
|
||||
'переносов'
|
||||
]);
|
||||
});
|
||||
|
||||
it('пустой и пробельный текст дают пустой массив', () => {
|
||||
expect(wrapText('', 100, measure)).toEqual([]);
|
||||
expect(wrapText(' \n\t ', 100, measure)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tileGrid', () => {
|
||||
it('стабильная сетка с шагом и центрированием', () => {
|
||||
const pts = tileGrid(200, 200, 0, 60, 60, 80, 24);
|
||||
expect(pts.length).toBeGreaterThan(0);
|
||||
const xs = new Set(pts.map((p) => p.x));
|
||||
const ys = new Set(pts.map((p) => p.y));
|
||||
expect(xs.size).toBeGreaterThan(1);
|
||||
expect(ys.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('кап защищает от гигантского количества плиток', () => {
|
||||
const pts = tileGrid(4000, 4000, 45, 8, 8, 100, 40);
|
||||
expect(pts.length).toBeLessThanOrEqual(2500);
|
||||
expect(pts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('обычные входные данные не триггерят кап', () => {
|
||||
const pts = tileGrid(800, 600, 30, 140, 90, 160, 40);
|
||||
expect(pts.length).toBeLessThanOrEqual(2500);
|
||||
expect(pts.length).toBeGreaterThan(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
export type Position9 =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'middle-left'
|
||||
| 'center'
|
||||
| 'middle-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right';
|
||||
|
||||
/**
|
||||
* Левый верхний угол контента размером contentW×contentH при размещении
|
||||
* на холсте cw×ch с отступом margin согласно 9-позиционной сетке.
|
||||
*/
|
||||
export function anchorOrigin(
|
||||
position: Position9,
|
||||
contentW: number,
|
||||
contentH: number,
|
||||
cw: number,
|
||||
ch: number,
|
||||
margin: number
|
||||
): { x: number; y: number } {
|
||||
const h = position.endsWith('-left') ? 'left' : position.endsWith('-right') ? 'right' : 'center';
|
||||
const v = position.startsWith('top-')
|
||||
? 'top'
|
||||
: position.startsWith('bottom-')
|
||||
? 'bottom'
|
||||
: 'middle';
|
||||
const x = h === 'left' ? margin : h === 'right' ? cw - margin - contentW : (cw - contentW) / 2;
|
||||
const y = v === 'top' ? margin : v === 'bottom' ? ch - margin - contentH : (ch - contentH) / 2;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Жадный перенос текста по словам под ширину maxWidth.
|
||||
* measure — инъекция измерителя ширины строки. Пустой текст → пустой массив строк.
|
||||
*/
|
||||
export function wrapText(
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
measure: (line: string) => number
|
||||
): string[] {
|
||||
const words = text.trim().split(/\s+/).filter((w) => w.length > 0);
|
||||
if (words.length === 0) return [];
|
||||
const lines: string[] = [];
|
||||
let current = '';
|
||||
for (const word of words) {
|
||||
const candidate = current.length === 0 ? word : `${current} ${word}`;
|
||||
if (measure(candidate) <= maxWidth || current.length === 0) {
|
||||
current = candidate;
|
||||
} else {
|
||||
lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
}
|
||||
if (current.length > 0) lines.push(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export type TilePoint = { x: number; y: number };
|
||||
|
||||
const MAX_TILES = 2500;
|
||||
|
||||
/**
|
||||
* Сетка позиций плитки водяного знака в повернутой системе координат:
|
||||
* рисующий код один раз поворачивает контекст и ставит блоки в этих точках.
|
||||
* Шаги автоматически увеличиваются, если расчетное количество плиток
|
||||
* превышает кап — большие холсты не подвешивают страницу.
|
||||
*/
|
||||
export function tileGrid(
|
||||
cw: number,
|
||||
ch: number,
|
||||
angleDeg: number,
|
||||
stepX: number,
|
||||
stepY: number,
|
||||
blockW: number,
|
||||
blockH: number
|
||||
): TilePoint[] {
|
||||
const diag = Math.sqrt(cw * cw + ch * ch);
|
||||
const spanX = diag + blockW;
|
||||
const spanY = diag + blockH;
|
||||
|
||||
let sx = Math.max(stepX, 1);
|
||||
let sy = Math.max(stepY, 1);
|
||||
for (let guard = 0; guard < 16; guard++) {
|
||||
const count = Math.ceil(spanX / sx) * Math.ceil(spanY / sy);
|
||||
if (count <= MAX_TILES) break;
|
||||
const factor = Math.sqrt(count / MAX_TILES) * 1.02;
|
||||
sx *= factor;
|
||||
sy *= factor;
|
||||
}
|
||||
|
||||
const cols = Math.max(1, Math.ceil(spanX / sx));
|
||||
const rows = Math.max(1, Math.ceil(spanY / sy));
|
||||
const startX = -spanX / 2 + (spanX - (cols - 1) * sx) / 2;
|
||||
const startY = -spanY / 2 + (spanY - (rows - 1) * sy) / 2;
|
||||
|
||||
const points: TilePoint[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
points.push({
|
||||
x: startX + c * sx - cw / 2,
|
||||
y: startY + r * sy - ch / 2
|
||||
});
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToImage,
|
||||
imageToByteRows,
|
||||
imageToRgbValues,
|
||||
looksLikePng,
|
||||
rgbValuesToImage,
|
||||
stripDataUri
|
||||
} from './textio';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const img = makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 128]
|
||||
]);
|
||||
|
||||
describe('bytes', () => {
|
||||
it('round-trip rows → image → rows', () => {
|
||||
const rows = imageToByteRows(img);
|
||||
expect(rows).toBe('255 0 0 255 0 255 0 128');
|
||||
const back = bytesToImage(rows, 2);
|
||||
expect([...back.data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it('некратное четырём число байт — ошибка', () => {
|
||||
expect(() => bytesToImage('1 2 3', 1)).toThrow(/errors\.bytesCount/);
|
||||
});
|
||||
|
||||
it('значение вне 0..255 — ошибка диапазона', () => {
|
||||
expect(() => bytesToImage('10 20 30 256', 1)).toThrow(/errors\.byteRange/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rgb values', () => {
|
||||
it('round-trip rgba-строк', () => {
|
||||
const rows = imageToRgbValues(img);
|
||||
expect(rows.split('\n')[0]).toBe('rgba(255, 0, 0, 255) rgba(0, 255, 0, 128)');
|
||||
const back = rgbValuesToImage(rows.replace(/rgba\(|\)/g, ''), 2);
|
||||
expect([...back.data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('png signature / data-uri', () => {
|
||||
it('looksLikePng: настоящая сигнатура и обрывок', () => {
|
||||
const good = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1]);
|
||||
const bad = new Uint8Array([137, 80, 78]);
|
||||
expect(looksLikePng(good)).toBe(true);
|
||||
expect(looksLikePng(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('stripDataUri снимает префикс и оставляет чистый base64', () => {
|
||||
expect(stripDataUri('data:image/png;base64,iVBORw==')).toBe('iVBORw==');
|
||||
expect(stripDataUri(' iVBORw==')).toBe('iVBORw==');
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64ToBytes', () => {
|
||||
it('декодирует известную строку', () => {
|
||||
const bytes = base64ToBytes('AAECAwQ=');
|
||||
expect([...bytes]).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ToolError } from './errors';
|
||||
import type { PixelImage } from './types';
|
||||
import { createPixelImage } from './types';
|
||||
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
/** Строки вида «r g b a» по одной строке на ряд пикселей. */
|
||||
export function imageToByteRows(img: PixelImage): string {
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const parts: string[] = [];
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
parts.push(`${img.data[i]} ${img.data[i + 1]} ${img.data[i + 2]} ${img.data[i + 3]}`);
|
||||
}
|
||||
rows.push(parts.join(' '));
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
export function bytesToImage(text: string, width: number): PixelImage {
|
||||
const nums = (text.match(/-?\d+/g) ?? []).map(Number);
|
||||
if (nums.length % 4 !== 0) {
|
||||
throw new ToolError('errors.bytesCount', { count: nums.length });
|
||||
}
|
||||
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
||||
throw new ToolError('errors.byteRange');
|
||||
}
|
||||
const w = Math.trunc(width);
|
||||
if (!Number.isInteger(w) || w < 1) throw new ToolError('errors.widthInt');
|
||||
if (nums.length / 4 % w !== 0) {
|
||||
throw new ToolError('errors.pixelCountMismatch', { count: nums.length / 4, width: w });
|
||||
}
|
||||
const out = createPixelImage(w, nums.length / 4 / w);
|
||||
out.data.set(nums);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Строки вида «rgba(r, g, b, a)», по одной на пиксель, ряды через перевод строки. */
|
||||
export function imageToRgbValues(img: PixelImage): string {
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const parts: string[] = [];
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
parts.push(`rgba(${img.data[i]}, ${img.data[i + 1]}, ${img.data[i + 2]}, ${img.data[i + 3]})`);
|
||||
}
|
||||
rows.push(parts.join(' '));
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
export function rgbValuesToImage(text: string, width: number): PixelImage {
|
||||
const nums = (text.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
|
||||
if (nums.length % 4 !== 0) {
|
||||
throw new ToolError('errors.bytesCount', { count: nums.length });
|
||||
}
|
||||
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
||||
throw new ToolError('errors.byteRange');
|
||||
}
|
||||
const w = Math.trunc(width);
|
||||
if (!Number.isInteger(w) || w < 1) throw new ToolError('errors.widthInt');
|
||||
const pxCount = nums.length / 4;
|
||||
if (pxCount % w !== 0) {
|
||||
throw new ToolError('errors.pixelCountMismatch', { count: pxCount, width: w });
|
||||
}
|
||||
const out = createPixelImage(w, pxCount / w);
|
||||
out.data.set(nums);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Снимает префикс data-uri (data:image/...;base64,) при наличии. */
|
||||
export function stripDataUri(text: string): string {
|
||||
const m = /^\s*data:[^,\s]*base64,/i.exec(text);
|
||||
return m ? text.slice(m[0].length) : text.trim();
|
||||
}
|
||||
|
||||
export function base64ToBytes(text: string): Uint8Array {
|
||||
const clean = text.replace(/\s+/g, '');
|
||||
const binary = atob(clean);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function looksLikePng(bytes: Uint8Array): boolean {
|
||||
if (bytes.length < PNG_SIGNATURE.length) return false;
|
||||
return PNG_SIGNATURE.every((v, i) => bytes[i] === v);
|
||||
}
|
||||
+13
-1
@@ -14,6 +14,7 @@ export const en: Dict = {
|
||||
color: 'Color',
|
||||
geometry: 'Geometry',
|
||||
filters: 'Filters',
|
||||
text: 'Text',
|
||||
analyze: 'Analyze',
|
||||
generate: 'Generate'
|
||||
},
|
||||
@@ -104,13 +105,17 @@ export const en: Dict = {
|
||||
reset: 'Reset',
|
||||
pipette: 'Eyedropper',
|
||||
themeLight: 'Light theme',
|
||||
themeDark: 'Dark theme'
|
||||
themeDark: 'Dark theme',
|
||||
overlayTitle: 'Watermark',
|
||||
overlayDrop: 'Drop a watermark PNG or click',
|
||||
overlayRemove: 'Remove watermark'
|
||||
},
|
||||
errors: {
|
||||
noImageRun: 'This tool does not process images',
|
||||
workerFailed: 'Worker execution failed',
|
||||
workerUnavailable: 'Worker is unavailable',
|
||||
notFound: 'Tool not found',
|
||||
noWatermark: 'Pick a watermark image first',
|
||||
badTransform: 'Degenerate transformation matrix',
|
||||
skewAngle: 'Skew angles cannot be 90° or -90°',
|
||||
badHex: 'Invalid HEX color: "{value}"',
|
||||
@@ -137,11 +142,18 @@ 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'
|
||||
},
|
||||
tools: {
|
||||
'verify-is-png': {
|
||||
results: {
|
||||
verifyYes: 'Yes — this is a valid PNG signature.',
|
||||
verifyNo: 'No — the signature does not match a PNG file.'
|
||||
}
|
||||
},
|
||||
'png-is-grayscale': {
|
||||
results: {
|
||||
grayscaleYes: 'Yes — all pixels are shades of gray.',
|
||||
|
||||
+581
-1
@@ -14,6 +14,7 @@ export const ru: Dict = {
|
||||
color: 'Цвет',
|
||||
geometry: 'Геометрия',
|
||||
filters: 'Фильтры',
|
||||
text: 'Текст',
|
||||
analyze: 'Анализ',
|
||||
generate: 'Генерация'
|
||||
},
|
||||
@@ -104,13 +105,17 @@ export const ru: Dict = {
|
||||
reset: 'Сбросить',
|
||||
pipette: 'Пипетка',
|
||||
themeLight: 'Светлая тема',
|
||||
themeDark: 'Тёмная тема'
|
||||
themeDark: 'Тёмная тема',
|
||||
overlayTitle: 'Знак',
|
||||
overlayDrop: 'Перетащите PNG-знак или нажмите',
|
||||
overlayRemove: 'Убрать знак'
|
||||
},
|
||||
errors: {
|
||||
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
||||
workerFailed: 'Ошибка исполнения в воркере',
|
||||
workerUnavailable: 'Воркер недоступен',
|
||||
notFound: 'Инструмент не найден',
|
||||
noWatermark: 'Сначала выберите картинку-знак',
|
||||
badTransform: 'Вырожденная матрица трансформации',
|
||||
skewAngle: 'Углы наклона не могут быть 90° или -90°',
|
||||
badHex: 'Некорректный HEX-цвет: "{value}"',
|
||||
@@ -137,6 +142,7 @@ export const ru: Dict = {
|
||||
noSteps: 'В файле нет списка шагов',
|
||||
paramNumber: 'Параметр "{id}" должен быть числом',
|
||||
paramString: 'Параметр "{id}" должен быть строкой',
|
||||
paramBool: 'Параметр "{id}" должен быть значением флажка',
|
||||
resizeSize: 'Ширина и/или высота должны быть положительными',
|
||||
cropSize: 'Ширина и высота области обрезки должны быть положительными',
|
||||
sizePositive: 'Размеры должны быть положительными и конечными'
|
||||
@@ -430,6 +436,109 @@ export const ru: Dict = {
|
||||
},
|
||||
options: { direction: { horizontal: 'По горизонтали', vertical: 'По вертикали' } }
|
||||
},
|
||||
'color-wheel-png': {
|
||||
title: 'Цветовой круг PNG',
|
||||
description:
|
||||
'Круг HSL: оттенок по окружности, насыщенность от центра к краю, выбранная светлота.',
|
||||
params: { width: 'Размер', lightness: 'Светлота, %' }
|
||||
},
|
||||
'complementary-png': {
|
||||
title: 'Комплементарная палитра PNG',
|
||||
description: 'Два противоположных цвета круга — базовый и его дополнение.',
|
||||
params: {
|
||||
baseColor: 'Базовый цвет',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'triadic-png': {
|
||||
title: 'Триадная палитра PNG',
|
||||
description: 'Три цвета через 120° друг от друга на цветовом круге.',
|
||||
params: { baseColor: 'Базовый цвет', width: 'Ширина', layout: 'Раскладка' },
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'tetradic-png': {
|
||||
title: 'Тетрадная палитра PNG',
|
||||
description: 'Четыре цвета — две комплементарные пары, шаг 90° по кругу.',
|
||||
params: { baseColor: 'Базовый цвет', width: 'Ширина', layout: 'Раскладка' },
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'analogous-png': {
|
||||
title: 'Аналоговая палитра PNG',
|
||||
description: 'Соседние оттенки вокруг базового — спокойная родственная гамма.',
|
||||
params: {
|
||||
baseColor: 'Базовый цвет',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка',
|
||||
spread: 'Разброс оттенка, °',
|
||||
count: 'Сколько цветов'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'monochromatic-png': {
|
||||
title: 'Монохромная палитра PNG',
|
||||
description:
|
||||
'Тоны одного оттенка: меняется светлота в выбранном диапазоне, тон и насыщенность фиксированы.',
|
||||
params: {
|
||||
baseColor: 'Базовый цвет',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка',
|
||||
count: 'Сколько цветов',
|
||||
range: 'Диапазон светлоты, %'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'shades-png': {
|
||||
title: 'Градация оттенка PNG',
|
||||
description: 'Ступени базового цвета от исходного к более тёмному.',
|
||||
params: {
|
||||
baseColor: 'Базовый цвет',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка',
|
||||
count: 'Сколько цветов',
|
||||
depth: 'Глубина затемнения, %'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'mix-colors-png': {
|
||||
title: 'Смешать цвета PNG',
|
||||
description:
|
||||
'Усредняет несколько hex-цветов в один свотч. Введите значения через запятую; неверные токены пропускаются.',
|
||||
params: { colors: 'Цвета (hex через запятую)', width: 'Ширина' }
|
||||
},
|
||||
'blend-two-png': {
|
||||
title: 'Перелив двух цветов PNG',
|
||||
description: 'Непрерывный горизонтальный градиент между двумя цветами.',
|
||||
params: { colorA: 'Цвет A', colorB: 'Цвет B', width: 'Ширина' }
|
||||
},
|
||||
'step-colors-png': {
|
||||
title: 'Ступени между цветами PNG',
|
||||
description: 'Дискретный набор равномерно распределённых ступеней между двумя цветами.',
|
||||
params: {
|
||||
colorA: 'Цвет A',
|
||||
colorB: 'Цвет B',
|
||||
steps: 'Сколько ступеней',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка'
|
||||
},
|
||||
options: { layout: { grid: 'Сетка', strip: 'Полоса' } }
|
||||
},
|
||||
'sort-colors-png': {
|
||||
title: 'Отсортировать цвета PNG',
|
||||
description:
|
||||
'Рисует ваш hex-список свотчами, отсортированными по тону, яркости или насыщенности. Неверные токены пропускаются.',
|
||||
params: {
|
||||
colors: 'Цвета (hex через запятую)',
|
||||
order: 'Сортировка',
|
||||
width: 'Ширина',
|
||||
layout: 'Раскладка'
|
||||
},
|
||||
options: {
|
||||
order: { hue: 'Оттенок', luma: 'Яркость', sat: 'Насыщенность' },
|
||||
layout: { grid: 'Сетка', strip: 'Полоса' }
|
||||
}
|
||||
},
|
||||
'png-is-grayscale': {
|
||||
title: 'Проверить: PNG монохромный?',
|
||||
description: 'Сообщает, состоит ли изображение только из оттенков серого.',
|
||||
@@ -438,6 +547,477 @@ export const ru: Dict = {
|
||||
grayscaleNo: 'Нет — найдены цветные пиксели.'
|
||||
}
|
||||
},
|
||||
'png-to-hsl': {
|
||||
title: 'Разложить PNG в HSL',
|
||||
description: 'Раскладывает изображение на компоненты Тон, Насыщенность и Светлота.',
|
||||
params: { component: 'Компонент', display: 'Режим показа' },
|
||||
options: {
|
||||
component: { h: 'Тон (H)', s: 'Насыщенность (S)', l: 'Светлота (L)' },
|
||||
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||
}
|
||||
},
|
||||
'png-to-hsv': {
|
||||
title: 'Разложить PNG в HSV',
|
||||
description: 'Раскладывает изображение на Тон, Насыщенность и Яркость (Value).',
|
||||
params: { component: 'Компонент', display: 'Режим показа' },
|
||||
options: {
|
||||
component: { h: 'Тон (H)', s: 'Насыщенность (S)', v: 'Яркость (V)' },
|
||||
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||
}
|
||||
},
|
||||
'png-to-hsi': {
|
||||
title: 'Разложить PNG в HSI',
|
||||
description: 'Раскладывает изображение на Тон, Насыщенность и Интенсивность.',
|
||||
params: { component: 'Компонент', display: 'Режим показа' },
|
||||
options: {
|
||||
component: { h: 'Тон (H)', s: 'Насыщенность (S)', i: 'Интенсивность (I)' },
|
||||
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||
}
|
||||
},
|
||||
'png-to-cmyk': {
|
||||
title: 'PNG в CMYK-цвета',
|
||||
description:
|
||||
'Раскладывает изображение на печатные компоненты: Голубой, Пурпурный, Жёлтый и Чёрный (Key).',
|
||||
params: { component: 'Компонент', display: 'Режим показа' },
|
||||
options: {
|
||||
component: {
|
||||
c: 'Голубой (C)',
|
||||
m: 'Пурпурный (M)',
|
||||
y: 'Жёлтый (Y)',
|
||||
k: 'Чёрный (K)'
|
||||
},
|
||||
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||
}
|
||||
},
|
||||
'png-to-ycbcr': {
|
||||
title: 'PNG в YCbCr-цвета',
|
||||
description:
|
||||
'Раскладывает изображение на Яркость (Y) и цветоразностные компоненты Cb / Cr.',
|
||||
params: { component: 'Компонент', display: 'Режим показа' },
|
||||
options: {
|
||||
component: { y: 'Яркость (Y)', cb: 'Синий-разностный (Cb)', cr: 'Красный-разностный (Cr)' },
|
||||
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||
}
|
||||
},
|
||||
'png-to-lab': {
|
||||
title: 'PNG в LAB-цвета',
|
||||
description:
|
||||
'Раскладывает изображение на perceptual-компоненты: Светлость, зелёный–пурпурный и синий–жёлтый.',
|
||||
params: { component: 'Компонент', display: 'Режим показа' },
|
||||
options: {
|
||||
component: {
|
||||
l: 'Светлость (L)',
|
||||
a: 'Зелёный–пурпурный (a)',
|
||||
b: 'Синий–жёлтый (b)'
|
||||
},
|
||||
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||
}
|
||||
},
|
||||
'circle-mask-png': {
|
||||
title: 'Круглая маска PNG',
|
||||
description:
|
||||
'Вырезает из изображения круг. Диаметр задаётся в процентах от меньшей стороны.',
|
||||
params: {
|
||||
size: 'Диаметр, % меньшей стороны',
|
||||
offsetX: 'Смещение X, %',
|
||||
offsetY: 'Смещение Y, %'
|
||||
}
|
||||
},
|
||||
'square-mask-png': {
|
||||
title: 'Прямоугольная маска PNG',
|
||||
description:
|
||||
'Вырезает прямоугольник со сторонами в процентах от меньшей стороны изображения.',
|
||||
params: {
|
||||
widthPct: 'Ширина, % меньшей стороны',
|
||||
heightPct: 'Высота, % меньшей стороны',
|
||||
offsetX: 'Смещение X, %',
|
||||
offsetY: 'Смещение Y, %'
|
||||
}
|
||||
},
|
||||
'star-mask-png': {
|
||||
title: 'Маска-звезда PNG',
|
||||
description:
|
||||
'Вырезает звезду с настраиваемым числом лучей, глубиной впадин и поворотом.',
|
||||
params: {
|
||||
points: 'Лучи',
|
||||
innerRadius: 'Радиус впадин, %',
|
||||
size: 'Внешний радиус, % меньшей стороны',
|
||||
rotation: 'Поворот, °',
|
||||
offsetX: 'Смещение X, %',
|
||||
offsetY: 'Смещение Y, %'
|
||||
}
|
||||
},
|
||||
'wavy-mask-png': {
|
||||
title: 'Волнистая маска PNG',
|
||||
description:
|
||||
'Вырезает круг с волнистым краем: радиус модулируется синусом с заданной амплитудой и частотой.',
|
||||
params: {
|
||||
size: 'Базовый радиус, % меньшей стороны',
|
||||
amplitude: 'Амплитуда волн, %',
|
||||
waves: 'Количество волн',
|
||||
phase: 'Фаза, °',
|
||||
offsetX: 'Смещение X, %',
|
||||
offsetY: 'Смещение Y, %'
|
||||
}
|
||||
},
|
||||
'trim-empty-space-png': {
|
||||
title: 'Обрезать пустые поля PNG',
|
||||
description:
|
||||
'Обрезает прозрачные рамки вокруг содержимого. Пиксели с альфой выше порога считаются содержимым.',
|
||||
params: { threshold: 'Порог альфы' }
|
||||
},
|
||||
'change-canvas-size-png': {
|
||||
title: 'Изменить размер холста PNG',
|
||||
description:
|
||||
'Задаёт точный размер холста: лишнее обрезается, недостающее дополняется прозрачностью. Якорь выбирает, какая часть изображения остаётся.',
|
||||
params: { width: 'Ширина', height: 'Высота', anchor: 'Якорь' },
|
||||
options: {
|
||||
anchor: {
|
||||
'top-left': 'Сверху слева',
|
||||
'top-center': 'Сверху по центру',
|
||||
'top-right': 'Сверху справа',
|
||||
'middle-left': 'По центру слева',
|
||||
center: 'По центру',
|
||||
'middle-right': 'По центру справа',
|
||||
'bottom-left': 'Снизу слева',
|
||||
'bottom-center': 'Снизу по центру',
|
||||
'bottom-right': 'Снизу справа'
|
||||
}
|
||||
}
|
||||
},
|
||||
'change-aspect-ratio-png': {
|
||||
title: 'Изменить соотношение сторон PNG',
|
||||
description:
|
||||
'Вписывает изображение в целевое соотношение сторон: обрезать центр до заполнения или дополнить прозрачностью.',
|
||||
params: { ratio: 'Целевое отношение', mode: 'Режим' },
|
||||
options: {
|
||||
ratio: {
|
||||
'1:1': '1:1',
|
||||
'4:3': '4:3',
|
||||
'3:4': '3:4',
|
||||
'3:2': '3:2',
|
||||
'2:3': '2:3',
|
||||
'16:9': '16:9',
|
||||
'9:16': '9:16'
|
||||
},
|
||||
mode: { crop: 'Обрезать до заполнения', pad: 'Дополнить до вписывания' }
|
||||
}
|
||||
},
|
||||
'swap-orientation-png': {
|
||||
title: 'Поменять ориентацию PNG',
|
||||
description:
|
||||
'Поворачивает изображение на 90°, если ориентация отличается от целевой — ландшафт становится портретом и наоборот. Квадрат не трогается.',
|
||||
params: { target: 'Целевая ориентация' },
|
||||
options: { target: { portrait: 'Портрет', landscape: 'Ландшафт' } }
|
||||
},
|
||||
'symmetric-copy-png': {
|
||||
title: 'Симметричная копия PNG',
|
||||
description:
|
||||
'Удваивает холст, зеркалируя сохранённую половину на пустую — мгновенный симметричный узор.',
|
||||
params: { axis: 'Линия зеркала', keepSide: 'Какая сторона остаётся' },
|
||||
options: {
|
||||
axis: {
|
||||
vertical: 'Вертикальная (ширина ×2)',
|
||||
horizontal: 'Горизонтальная (высота ×2)'
|
||||
},
|
||||
keepSide: { left: 'Левая', right: 'Правая', top: 'Верхняя', bottom: 'Нижняя' }
|
||||
}
|
||||
},
|
||||
'feather-edges-png': {
|
||||
title: 'Растушевать края PNG',
|
||||
description:
|
||||
'Размывает только альфа-канал: жёсткие края выреза становятся мягкими, цвета не трогаются.',
|
||||
params: { radius: 'Радиус растушёвки, px' }
|
||||
},
|
||||
'clean-edges-png': {
|
||||
title: 'Почистить края PNG (defringe)',
|
||||
description:
|
||||
'Заменяет цветную кайму полупрозрачных пикселей цветом ближайшего полностью непрозрачного соседа. Альфа остаётся как была.',
|
||||
params: { radius: 'Радиус поиска, px' }
|
||||
},
|
||||
'pixelate-png': {
|
||||
title: 'Пикселизация PNG',
|
||||
description: 'Усредняет каждый блок blockSize×blockSize в один цвет — классическая мозаика.',
|
||||
params: { blockSize: 'Размер блока, px' }
|
||||
},
|
||||
'randomize-pixels-png': {
|
||||
title: 'Перемешать пиксели PNG',
|
||||
description:
|
||||
'Переставляет блоки изображения между собой. Одинаковый seed даёт одинаковую раскладку.',
|
||||
params: { blockSize: 'Размер блока, px', seed: 'Seed' }
|
||||
},
|
||||
'add-noise-png': {
|
||||
title: 'Добавить шум PNG',
|
||||
description:
|
||||
'Добавляет зерно в стиле плёнки. Детерминировано по seed; монохромный сохраняет баланс оттенков.',
|
||||
params: { amount: 'Сила, %', mode: 'Тип шума', seed: 'Seed' },
|
||||
options: { mode: { mono: 'Монохромное зерно', color: 'Цветной шум' } }
|
||||
},
|
||||
'silhouette-png': {
|
||||
title: 'Силуэт PNG',
|
||||
description:
|
||||
'Заливает все видимые пиксели одним цветом, сохраняя их прозрачность — мгновенный силуэт.',
|
||||
params: { color: 'Цвет силуэта', threshold: 'Порог видимости, %' }
|
||||
},
|
||||
'png-to-bytes': {
|
||||
title: 'PNG в байты',
|
||||
description: 'Выводит каждый пиксель четырьмя десятичными байтами (R G B A), по строке изображения на строку текста.'
|
||||
},
|
||||
'bytes-to-png': {
|
||||
title: 'Байты в PNG',
|
||||
description:
|
||||
'Собирает изображение из десятичных RGBA-байтов (разделители любые). Укажите ширину — высота рассчитается сама.',
|
||||
params: { width: 'Ширина изображения' }
|
||||
},
|
||||
'png-to-rgb-values': {
|
||||
title: 'PNG в RGB-значения',
|
||||
description: 'Выводит каждый пиксель как rgba(r, g, b, a), ряды через перевод строки.'
|
||||
},
|
||||
'rgb-values-to-png': {
|
||||
title: 'RGB-значения в PNG',
|
||||
description:
|
||||
'Собирает изображение из чисел rgba(r, g, b, a). Укажите ширину — высота рассчитается сама.',
|
||||
params: { width: 'Ширина изображения' }
|
||||
},
|
||||
'verify-is-png': {
|
||||
title: 'Проверить: это PNG?',
|
||||
description:
|
||||
'Проверяет сигнатуру вставленного base64 / data-uri и сообщает, настоящий ли это PNG.',
|
||||
results: {
|
||||
verifyYes: 'Да — сигнатура настоящего PNG.',
|
||||
verifyNo: 'Нет — сигнатура не совпадает с PNG-файлом.'
|
||||
}
|
||||
},
|
||||
'text-to-png': {
|
||||
title: 'Текст в PNG',
|
||||
description: 'Создаёт PNG из текста: холст подгоняется под размер надписи с полями.',
|
||||
params: {
|
||||
text: 'Текст',
|
||||
fontSize: 'Размер шрифта, px',
|
||||
font: 'Шрифт',
|
||||
bold: 'Жирный',
|
||||
color: 'Цвет текста',
|
||||
transparentBg: 'Прозрачный фон',
|
||||
backgroundColor: 'Цвет фона',
|
||||
padding: 'Отступ, px'
|
||||
},
|
||||
options: {
|
||||
font: { sans: 'Без засечек', serif: 'С засечками', mono: 'Моноширинный' }
|
||||
}
|
||||
},
|
||||
'emoji-to-png': {
|
||||
title: 'Эмодзи в PNG',
|
||||
description: 'Рисует эмодзи или любой Unicode-символ как прозрачный PNG выбранного размера.',
|
||||
params: { emoji: 'Эмодзи / символ', size: 'Размер' }
|
||||
},
|
||||
'placeholder-png': {
|
||||
title: 'Заглушка PNG',
|
||||
description: 'Генерирует прямоугольник-заглушку с напечатанными размерами в центре.',
|
||||
params: {
|
||||
width: 'Ширина',
|
||||
height: 'Высота',
|
||||
backgroundColor: 'Фон',
|
||||
color: 'Цвет текста',
|
||||
showText: 'Печатать размеры'
|
||||
}
|
||||
},
|
||||
'color-spectrum-png': {
|
||||
title: 'Спектр цветов PNG',
|
||||
description:
|
||||
'Полный радужный переход 0–360° вдоль выбранной оси с настройкой насыщенности и светлоты.',
|
||||
params: {
|
||||
width: 'Ширина',
|
||||
height: 'Высота',
|
||||
direction: 'Направление',
|
||||
saturation: 'Насыщенность, %',
|
||||
lightness: 'Светлота, %'
|
||||
},
|
||||
options: { direction: { horizontal: 'По горизонтали', vertical: 'По вертикали' } }
|
||||
},
|
||||
'random-colors-png': {
|
||||
title: 'Случайные цветные блоки PNG',
|
||||
description: 'Заполняет холст случайными яркими блоками. Детерминировано по seed.',
|
||||
params: { width: 'Ширина', height: 'Высота', blockSize: 'Размер блока, px', seed: 'Seed' }
|
||||
},
|
||||
'draw-grid-png': {
|
||||
title: 'Нарисовать сетку PNG',
|
||||
description:
|
||||
'Рисует сетку с заданными столбцами, строками и толщиной линий на прозрачном или белом фоне.',
|
||||
params: {
|
||||
width: 'Ширина',
|
||||
height: 'Высота',
|
||||
cols: 'Столбцы',
|
||||
rows: 'Строки',
|
||||
lineWidth: 'Толщина линий, px',
|
||||
color: 'Цвет линий',
|
||||
transparentBg: 'Прозрачный фон'
|
||||
}
|
||||
},
|
||||
'show-transparent-png': {
|
||||
title: 'Показать прозрачные области PNG',
|
||||
description:
|
||||
'Подсвечивает выбранным цветом каждый прозрачный или полупрозрачный пиксель — дыры становятся заметными.',
|
||||
params: {
|
||||
mode: 'Режим маски',
|
||||
color: 'Цвет подсветки',
|
||||
opacity: 'Непрозрачность подсветки, %'
|
||||
},
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'show-grayscale-pixels-png': {
|
||||
title: 'Показать серые пиксели PNG',
|
||||
description:
|
||||
'Находит пиксели с почти равными каналами и рисует их маску. Допуск — в единицах канала.',
|
||||
params: { tolerance: 'Допуск по каналам', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'show-color-pixels-png': {
|
||||
title: 'Показать цветные пиксели PNG',
|
||||
description: 'Находит цветные (не серые) пиксели за пределами допуска и рисует их маску.',
|
||||
params: { tolerance: 'Допуск по каналам', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'light-pixel-mask-png': {
|
||||
title: 'Маска светлых пикселей PNG',
|
||||
description: 'Выбирает пиксели ярче порога яркости.',
|
||||
params: { threshold: 'Порог яркости, %', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'dark-pixel-mask-png': {
|
||||
title: 'Маска тёмных пикселей PNG',
|
||||
description: 'Выбирает пиксели темнее порога яркости.',
|
||||
params: { threshold: 'Порог яркости, %', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'unique-color-mask-png': {
|
||||
title: 'Маска уникальных цветов PNG',
|
||||
description:
|
||||
'Выбирает цвета, которые встречаются не чаще заданного числа раз, — редкие и одиночные пиксели.',
|
||||
params: { rarity: 'Максимум повторов', mode: 'Режим маски', color: 'Цвет подсветки', opacity: 'Непрозрачность подсветки, %' },
|
||||
options: {
|
||||
mode: { binary: 'Чёрно-белая маска', highlight: 'Цветная подсветка' }
|
||||
}
|
||||
},
|
||||
'extract-color-from-png': {
|
||||
title: 'Извлечь цвет из PNG',
|
||||
description:
|
||||
'Оставляет только пиксели, близкие к выбранному цвету, остальное делает прозрачным — обратное «Удалить цвет».',
|
||||
params: { color: 'Какой цвет оставить', tolerance: 'Допуск похожести, %' }
|
||||
},
|
||||
'watermark-tile-png': {
|
||||
title: 'Плитка-водяной знак PNG',
|
||||
description:
|
||||
'Покрывает изображение повторяющейся диагональной полупрозрачной плиткой текста — защитный водяной знак.',
|
||||
params: {
|
||||
text: 'Текст',
|
||||
fontSize: 'Размер шрифта, px',
|
||||
color: 'Цвет текста',
|
||||
opacity: 'Непрозрачность, %',
|
||||
angle: 'Угол, °',
|
||||
stepX: 'Шаг X, px',
|
||||
stepY: 'Шаг Y, px',
|
||||
font: 'Шрифт',
|
||||
bold: 'Жирный'
|
||||
},
|
||||
options: {
|
||||
font: { sans: 'Без засечек', serif: 'С засечками', mono: 'Моноширинный' }
|
||||
}
|
||||
},
|
||||
'watermark-image-png': {
|
||||
title: 'Знак картинкой PNG',
|
||||
description:
|
||||
'Накладывает другой PNG (логотип, подпись): масштаб от ширины холста, прозрачность, позиция 3×3. Знак живёт, пока открыта страница, — после восстановления цепочки выберите его заново.',
|
||||
params: {
|
||||
scale: 'Ширина знака, % от холста',
|
||||
opacity: 'Непрозрачность, %',
|
||||
position: 'Позиция',
|
||||
margin: 'Отступ, px'
|
||||
},
|
||||
options: {
|
||||
position: {
|
||||
'top-left': 'Сверху слева',
|
||||
'top-center': 'Сверху по центру',
|
||||
'top-right': 'Сверху справа',
|
||||
'middle-left': 'По центру слева',
|
||||
center: 'По центру',
|
||||
'middle-right': 'По центру справа',
|
||||
'bottom-left': 'Снизу слева',
|
||||
'bottom-center': 'Снизу по центру',
|
||||
'bottom-right': 'Снизу справа'
|
||||
}
|
||||
}
|
||||
},
|
||||
'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: 'Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TOOLS } from '../registry';
|
||||
import { normalizeForSearch, scoreDoc } from './matching';
|
||||
import { toolSearchDoc } from './tool-strings';
|
||||
import { isChainable } from '../registry';
|
||||
|
||||
function hits(q: string): string[] {
|
||||
const nq = normalizeForSearch(q);
|
||||
return TOOLS.filter((t) => {
|
||||
const s = scoreDoc(toolSearchDoc(t), nq);
|
||||
return s !== null && s > 0;
|
||||
}).map((t) => t.id);
|
||||
}
|
||||
|
||||
describe('полнота поиска', () => {
|
||||
it('генератор (не chainable) находится поиском — раньше отфильтровывался', () => {
|
||||
expect(isChainable(TOOLS.find((t) => t.id === 'color-wheel-png')!)).toBe(false);
|
||||
expect(hits('color wheel')).toContain('color-wheel-png');
|
||||
});
|
||||
|
||||
it('анализатор-маска тоже ищется', () => {
|
||||
expect(hits('уникальных цветов')).toContain('unique-color-mask-png');
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,9 @@ describe('реестр инструментов', () => {
|
||||
|
||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
||||
if (tool.resultType === 'text') {
|
||||
expect(typeof tool.toText).toBe('function');
|
||||
expect(
|
||||
typeof tool.toText === 'function' || typeof tool.textToText === 'function'
|
||||
).toBe(true);
|
||||
} else if (tool.sourceMode === 'none') {
|
||||
expect(typeof tool.generate).toBe('function');
|
||||
} else {
|
||||
|
||||
+1351
-39
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { ToolError } from '../core/errors';
|
||||
|
||||
type MaybeRunnable = {
|
||||
id: string;
|
||||
domOnly?: boolean;
|
||||
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
|
||||
/**
|
||||
* Картинка-знак для watermark-image-png. Живёт только в состоянии страницы:
|
||||
* сознательно не сохраняется в pipeline/localStorage — после восстановления
|
||||
* цепочки знак нужно выбрать заново.
|
||||
*/
|
||||
let overlay = $state<PixelImage | null>(null);
|
||||
|
||||
export function getOverlay(): PixelImage | null {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
export function setOverlay(img: PixelImage): void {
|
||||
overlay = img;
|
||||
}
|
||||
|
||||
export function clearOverlay(): void {
|
||||
overlay = null;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
import {
|
||||
AppWindow,
|
||||
Binary,
|
||||
Blend,
|
||||
CalendarDays,
|
||||
ClipboardPaste,
|
||||
Circle,
|
||||
Contrast,
|
||||
Crop,
|
||||
Crosshair,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
Layers,
|
||||
Link2,
|
||||
Maximize2,
|
||||
Moon,
|
||||
PaintBucket,
|
||||
Palette,
|
||||
Rainbow,
|
||||
@@ -35,8 +38,13 @@ import {
|
||||
Scan,
|
||||
Scissors,
|
||||
SearchCheck,
|
||||
FaceSlightlySmiling,
|
||||
Square,
|
||||
Stamp,
|
||||
Star,
|
||||
Sun,
|
||||
Type,
|
||||
WavesHorizontal,
|
||||
ZoomIn,
|
||||
ZoomOut
|
||||
} from '@lucide/svelte';
|
||||
@@ -94,6 +102,60 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
||||
'sharpen-png': Focus,
|
||||
'remove-background-png': Scissors,
|
||||
'add-stroke-png': Square,
|
||||
'add-text-png': Type,
|
||||
'date-stamp-png': CalendarDays,
|
||||
'png-to-hsl': Blend,
|
||||
'png-to-hsv': Droplet,
|
||||
'png-to-hsi': Focus,
|
||||
'png-to-cmyk': PaintBucket,
|
||||
'png-to-ycbcr': Layers,
|
||||
'png-to-lab': Ruler,
|
||||
'show-transparent-png': Eye,
|
||||
'show-grayscale-pixels-png': Contrast,
|
||||
'show-color-pixels-png': Rainbow,
|
||||
'light-pixel-mask-png': Sun,
|
||||
'dark-pixel-mask-png': Moon,
|
||||
'unique-color-mask-png': Dices,
|
||||
'extract-color-from-png': Focus,
|
||||
'feather-edges-png': Droplets,
|
||||
'clean-edges-png': Scissors,
|
||||
'pixelate-png': Grid3x3,
|
||||
'randomize-pixels-png': Dices,
|
||||
'add-noise-png': Hash,
|
||||
'silhouette-png': Contrast,
|
||||
'trim-empty-space-png': Crop,
|
||||
'change-canvas-size-png': Frame,
|
||||
'change-aspect-ratio-png': Scaling,
|
||||
'swap-orientation-png': RotateCw,
|
||||
'symmetric-copy-png': FlipHorizontal2,
|
||||
'png-to-bytes': Binary,
|
||||
'bytes-to-png': Binary,
|
||||
'png-to-rgb-values': Hash,
|
||||
'rgb-values-to-png': Hash,
|
||||
'verify-is-png': SearchCheck,
|
||||
'text-to-png': Type,
|
||||
'emoji-to-png': FaceSlightlySmiling,
|
||||
'placeholder-png': Frame,
|
||||
'color-spectrum-png': Rainbow,
|
||||
'random-colors-png': Dices,
|
||||
'draw-grid-png': Grid3x3,
|
||||
'circle-mask-png': Circle,
|
||||
'square-mask-png': Square,
|
||||
'star-mask-png': Star,
|
||||
'wavy-mask-png': WavesHorizontal,
|
||||
'watermark-tile-png': Stamp,
|
||||
'watermark-image-png': FileImage,
|
||||
'color-wheel-png': Rainbow,
|
||||
'complementary-png': Contrast,
|
||||
'triadic-png': Hash,
|
||||
'tetradic-png': Grid3x3,
|
||||
'analogous-png': Blend,
|
||||
'monochromatic-png': Droplets,
|
||||
'shades-png': Sun,
|
||||
'mix-colors-png': PaintBucket,
|
||||
'blend-two-png': Layers,
|
||||
'step-colors-png': Scaling,
|
||||
'sort-colors-png': SearchCheck,
|
||||
'find-contour-png': Scan,
|
||||
'make-thicker-png': ZoomIn,
|
||||
'make-thinner-png': ZoomOut,
|
||||
|
||||
Reference in New Issue
Block a user