Compare commits

...
14 Commits
40 changed files with 4754 additions and 56 deletions
+44
View File
@@ -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
View File
@@ -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.
+72
View File
@@ -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 (FloydSteinberg/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 — не копируем.
## Оценка масштаба
W1W7 дают **~47 новых инструментов**, почти все EASY. W8 добавляет ядро квантования (открывает custom-palette/dithering и улучшает W9). Суммарно каталог достигает ~120+ при текущих 71.
+207
View File
@@ -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 клонов у источника) — сознательно не копируем: это обычные операции над конкретным контентом, у нас они доступны через базовые инструменты + цепочки.
+3 -1
View File
@@ -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'
];
+8
View File
@@ -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}
+31 -3
View File
@@ -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>
+11 -2
View File
@@ -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>
+65
View File
@@ -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);
});
});
+128
View File
@@ -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;
}
+19
View File
@@ -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М');
});
});
+26
View File
@@ -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;
}
});
}
+197
View File
@@ -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);
}
+129
View File
@@ -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
});
});
+44
View File
@@ -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);
});
});
+106
View File
@@ -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;
}
+155 -1
View File
@@ -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;
}
+80
View File
@@ -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);
});
});
+114
View File
@@ -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;
};
}
+138
View File
@@ -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);
});
});
+264
View File
@@ -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;
}
}
}
+117
View File
@@ -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()]);
});
});
+190
View File
@@ -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;
}
+55
View File
@@ -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);
});
});
+80
View File
@@ -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;
}
+66
View File
@@ -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);
});
});
+109
View File
@@ -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;
}
+63
View File
@@ -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]);
});
});
+89
View File
@@ -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
View File
@@ -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
View File
@@ -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: 'Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.',
+24
View File
@@ -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');
});
});
+3 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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);
}
+20
View File
@@ -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;
}
+63 -1
View File
@@ -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,