Compare commits

...
12 Commits
32 changed files with 893 additions and 472 deletions
+14
View File
@@ -2,6 +2,20 @@
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
- - -
## [v0.3.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/a9327eaa2757917fc1d58fb0a2989e98a0e1b07c..v0.3.0) - 2026-10-06
#### Features
- add info\warning badge, update size range workflow - ([92cc590](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/92cc5909594a8fe25f6cc70427b219e0dc22d774)) - Ku6epXBOCTuK
- add transparency range slider to opacity fields - ([ba533e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ba533e67fdba5e6b40f21c6b3ca375cf16426fef)) - Ku6epXBOCTuK
- upload image on click, tooltip - ([a0bc72e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a0bc72ef5d06b65507d6fe2ee3150872dbc146ae)) - Ku6epXBOCTuK
- split to parts show sizes of parts - ([684423d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/684423de679fc1b65195bd4fb1ce43290577722e)) - Ku6epXBOCTuK
#### Bug Fixes
- max height for source preview - ([0753c95](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0753c95583e83adb9fa25779bb5263a5cfe93ba2)) - Ku6epXBOCTuK
- scroll in result for split parts - ([7763fb7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7763fb7faa790c2e41f85362d375f576f28a4de1)) - Ku6epXBOCTuK
- range slider drag after regression - ([f00dae8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f00dae8b0158b9ff779511e1dd46cda8f5b6cb14)) - Ku6epXBOCTuK
- update split into parts max params - ([2a282b4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2a282b48f8d6be2bd3a4079f3a1a77031072b970)) - Ku6epXBOCTuK
- - -
## [v0.2.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f40a7c89ae5615f2c16f1154116d35fdc0e53125..v0.2.0) - 2026-10-04
#### Features
- add pipeline flow - ([6b946bf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b946bfb08729ead2515e87406cb3efd22ca9859)) - Ku6epXBOCTuK
+196 -201
View File
@@ -3,83 +3,88 @@
> Статус: **active**. Открытые задачи. Подробные планы, разбор решений и сырые
> находки — `docs/plan-seo.md`, `docs/plan-platform.md`,
> `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`.
>
> Все пункты — чекбоксы; нумерация «Идея N» в тексте сквозная по документу и
> сохраняется ради перекрёстных ссылок («идея 4 зависит от идеи 3»). Оценки
> (S/M/L) — оценка труда, не приоритет.
## lint и прочие правила
## Pipeline
- [ ] `tseslint.config(...)` deprecated: сигнатура
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
core, а не `typescript-eslint`) — `web/eslint.config.js`.
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого действует pattern-based policy-исключение
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
## Мелочи всякие
- [ ] названия инструментов - сейчас в pipeline инструмент называется "обрезать
png", но он работает с любыми картинками, наверное надо убрать. но в
заголовке - возможно надо все форматы указать (SEO задача?)
- [ ] кнопка добавить инструмент в pipeline - обдумать селектор с номальным
- [ ] названия инструментов — в pipeline инструмент называется «обрезать png»,
но он работает с любыми картинками, наверное надо убрать; в заголовке
страницы — возможно, надо указать все форматы (связано с SEO)
- [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным
поиском, фильтром возможных инструментов и т.д.
- [ ] pipeline - промежуточные результаты
- [ ] pipeline - drag-n-drop инструментов
- [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для
нарезки ассетов
- [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке
карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте
(любой шаг, не только первый), остальные шаги и их параметры сохраняются,
вход перечитывается. Открытый вопрос: что делать с несовместимыми входами
(text-source ↔ file).
- [ ] **Идея 2. Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания
звена до заголовка «Шаг n: название» (превью скрываются). Состояние
свёрнутости помнить в workspace-pipeline.
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
Именованные цепочки в localStorage (список, создать/переименовать/
удалить), быстрое переключение. Формат экспортного JSON расширить
опциональным полем имени; обратной совместимости со старыми выгрузками не
делаем — формат не устоялся: он появится вместе с фазой 11
`docs/roadmap.md` и до её старта может измениться. Плюс отдельная страница
со списком всех цепочек: удалить, переименовать, добавить новую,
«использовать» (переход на страницу инструмента с этой цепочкой). На
странице — упрощённый вид: только список инструментов цепочки, без
реального выполнения и параметров.
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход —
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного
сохранённого chain по каждому файлу, сборка результата обратно в zip для
скачивания. Зависит от идеи 3 (выбор цепочки) и от экспортного формата
pipeline JSON; прогресс и ошибки — пофайлово.
- [ ] **Идея 15. Набор файлов в пайплайне: 1 → many не должен быть
терминальным** — оценка L. **Сейчас** `execute` принимает один
`source: PixelImage | undefined` и возвращает один `ToolResult`, а
`SchemaToolView` раскладывает вывод в `result` / `fileResult` /
`textResult` по `resultKind` — поэтому шаг с `result: "files"` упирается в
конец цепочки. **Направление:** следующий инструмент применяется **ко всем
картинкам** предыдущего результата (fan-out), а many → 1 (вход = архив или
несколько файлов) — обратная сторона того же контракта. Терминальными
остаются только результаты, которые не картинка (text, verdict). Что
обдумать: - где проходит граница: один вызов `run(ctx)` на файл (имя части
склеивается в `FileResult.files`) или батч целиком на стороне исполнителя;
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
прогресс и отмена; - частичные ошибки: один битый файл не должен ронять
весь шаг — нужен per-file отчёт, а не один `ToolError` на шаг; - имена
частей на каждом шаге и Download для набора (сейчас zip по `page.slug`,
`SchemaToolView.download()`); - обратная сторона: распаковка zip в
браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
архиву), `docs/roadmap.md` фаза 8 «batch-страница».
- [ ] **Идея 17. Промежуточные результаты шагов + выравнивание рядов** — оценка
M. Два режима просмотра цепочки: - **без выравнивания (по умолчанию):** в
панели просмотра цепочка изображений по 2 в колонку «исходник → результат
1 → результат 2 → … → конечный результат»; - **с выравниванием (галочка
«выровнять инструменты и просмотр»):** у каждого инструмента показаны его
исходник и результат построчно «параметры — исходник — результат»;
промежуточные картинки при этом осознанно дублируются на экране —
результат шага является исходником следующего.
## Инструменты
- [ ] qr code to text, text to qr code
- [ ] bar code to text, text to bar code
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
схема + core, из задачи C3)
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
(`docs/testing-strategy.md`, раздел 5)
- [ ] e2e webkit: случайные падения по таймауту 5с (элемент не появляется),
каждый прогон падают разные тесты — webkit под Windows слишком медленный.
Рассмотреть увеличение timeout/expectTimeout для webkit-проекта в
`playwright.config.ts`. После фикса перепроверить `tool-flows.spec.ts`
«chain: remove step» — на webkit удаление шага не успевает за 5с (на
остальных движках стабильно)
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
ничем не читают. Либо показывать подсказку для инструментов с пустой
схемой (FA-E1), либо удалить ключ из обеих локалей
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
привязанные к разметке или svelte-классам (сейчас остались:
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
правило такое (запретить toContain без i18n)
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
какой размер - 112\56\28px или 72\36\18)
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
файлов, посмотреть реализацию в
[https://github.com/tma02/twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
и т.д., сделать lint правило
- [ ] комментарии на английском, lint правило
- [ ] уменьшить количество и многословность комментариев (lint правило?)
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
должны быть настраиваемой 0-100%, а не переключателем
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
единую структуру, позволит упростить некоторые вещи - например
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
`ondownloadtxt`, `ondownload`,
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
[twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
паспортом, семейства с кириллицей)
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
- [ ] проверить настройки range slider на crop-png - слишком большие значения
- [ ] обновить страницу kit или сделать storybook
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
@@ -87,6 +92,87 @@
`PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
вывода
- [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке
Download** — оценка M/L. Решение и продуктовое обоснование:
`docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет
существующие convert-страницы — `docs/plan-seo.md` §5. Механическая часть
уже есть: `Tool.output` (`OutputFormat`: mime/ext/qualityParamId) и
кодирование по нему в `SchemaToolView.download()` —
`docs/architecture.md`, раздел 3. Что осталось сверх S1f: - экспорт с
лимитом размера → «сжатие до целевого КБ»: для lossy это quality в
селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой
download-флоу.
- [ ] **Идея 7. Страница `watermark-image-png` (водяной знак картинкой)** —
оценка M. Нужна overlay-механика: второй источник изображения
(getOverlay/store), параметры scale/opacity/position/margin/aspect.
Решается вместе с вопросом, как второй источник картинки вписывается в
schema-driven preview (`SchemaToolView`).
- [ ] **Идея 8. `png-info` (детальная информация о PNG)** — оценка M/L.
Отдельный райз: exif-теги, редактирование, структурированный вывод; нужен
свой отдельный случай в UI, не «ещё один text/verdict». Возможно нужна
вообще отдельная страница, несовместимая с другими инструментами: 1) она
работает не с изображением, а с метаданными из файла, 2) она не может быть
частью цепочки, т.к. результат не является изображением, 3) если надо
изменить теги, никакого форматирования и изменения в изображение вносить
не надо, только в метаинформацию.
- [ ] **Идея 9. Region-инструменты** (censor/erase/pixelate-area/blur-area/
sharpen-area/reverse-area) — ждут UI выделения области на превью. Вне
очереди.
- [ ] **Идея 10. Мультифайловые инструменты-потребители** (gif-to-frames,
separate-colors) — механизм «результат = набор файлов» (1 → many) уже есть
(`docs/architecture.md`, раздел 7), первый потребитель —
`split-into-parts-png`. Остальные подключаются по мере нужды.
- [ ] **Идея 11. Анимационные** (slow-reveal/fade/scrolling) — выход не PNG;
отдельное решение о формате.
- [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract,
handwritten-digital).
- [ ] **Идея 14. Отображение маски для инструментов (`runMask`)** — придумать и
реализовать процесс показа маски результата. **Контекст:** удалённый UI-v1
умел показывать ч/б превью-маску «что будет вырезано» отдельным полем; при
переходе на единый `run(ctx)` этого поля не осталось — executor и
schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:**
дать каждому `run` возможность вернуть (или инструмент — объявить)
`runMask` — ч/б изображение маски изменений либо иной подход (отдельное
поле `mask` + функция маски, второй результат, overlay). Оценка — на
усмотрение при проработке (S/M). Пока живём без неё. **Идея 2:** каждый
`run` возвращает два изображения — для отображения (показывает маску или
сам результат) и для передачи в следующий инструмент (всегда результат).
Оценка — на усмотрение при проработке (S/M). Пока живём без неё. Первые
кандидаты на маску: `remove-color-from-png`, `remove-background-png`.
## UI и качество
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
ничем не читают. Либо показывать подсказку для инструментов с пустой
схемой (FA-E1), либо удалить ключ из обеих локалей
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
черная\белая\цветные\серая для проверки прозрачных картинок
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
единую структуру, позволит упростить некоторые вещи - например
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
`ondownloadtxt`, `ondownload`,
- [ ] resize keep aspect - работает, но немного не очевидное поведение при
прямоугольной картинке и выборе размера через быстрые кнопки (например
256х256). Воспроизведение поведения: keep aspect включено, загружаем
прямоугольную картинку 832 × 1232, картинка рескейлится до 173 × 256,
выключаем keep aspect, картинка рескейлится до 256 × 256 (с искажением
пропорций), включаем keep aspect - картинка рескейлится до размера 256 ×
379
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
- [ ] обновить страницу kit или сделать storybook
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
поэтому она не следует за пропорциями результата — широкий результат
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
@@ -95,20 +181,51 @@
- [ ] Радиусы бордеров: `--radius-m` ненулевой и применён ко всем бордерам
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
в дизайне, а где должен быть 0
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
- [ ] **Идея 5. Избранные инструменты (fav tools)** — оценка S/M.
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке
страницы инструмента; список slug в localStorage. Избранное показывается
отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к
popularity при скоринге).
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
`--color-main`/`--color-accent` подняты под текущий порог
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
для обычного текста, а `--color-danger` в light-теме на границе: после
завершения редизайна вернуть порог 4.5 и перепроверить оба токена.
- [ ] **Идея 16. Главная должна отличаться от каталога** — оценка S. Сейчас
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
Замысел: главная — крупнее и с более простым поиском, без категорий;
каталог остаётся группировкой с фильтрами. Детектор дублирования разметки
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
ловится на ревью.
## Инструменты
## Тесты и lint
- [ ] qr code to text, text to qr code
- [ ] bar code to text, text to bar code
## Переезд
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
plurals есть все формы, для которых нужен перевод, `other` никогда не
пустой, число подставляется в текст без склейки
- [ ] `tseslint.config(...)` deprecated: сигнатура
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
core, а не `typescript-eslint`) — `web/eslint.config.js`.
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого действует pattern-based policy-исключение
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
(`docs/testing-strategy.md`, раздел 5)
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
привязанные к разметке или svelte-классам (сейчас остались:
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
правило такое (запретить toContain без i18n)
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
и т.д., сделать lint правило
- [ ] комментарии на английском, lint правило
- [ ] уменьшить количество и многословность комментариев (lint правило?)
## SEO/GEO
@@ -139,133 +256,11 @@
- [ ] Шрифты: лицензирование (F1–F4) и DOM-free рендер (F5–F6) — до фазы 8
(`docs/plan-platform.md` §4)
## Качество production UI
## Переезд
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
## Идеи
Идеи 1–5 и 15 — про пайплайн (фаза 11 `docs/roadmap.md`). Идеи 6–14 и 16 — про
существующие инструменты и UI. Оценки (S/M/L) — оценка труда, не приоритет.
1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент
зафиксирован при открытии страницы; хочется заменить его на другой без
разборки цепочки: шаги и их параметры сохраняются, вход перечитывается.
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
file).
2. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
помнить в workspace-pipeline.
3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
в localStorage (список, создать/переименовать/удалить), быстрое переключение.
Формат экспортного JSON расширить опциональным полем имени; обратной
совместимости со старыми выгрузками не делаем — формат не устоялся: он
появится вместе с фазой 11 `docs/roadmap.md` и до её старта может измениться.
4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
chain по каждому файлу, сборка результата обратно в zip для скачивания.
Зависит от идеи 3 (выбор цепочки) и от экспортного формата pipeline JSON;
прогресс и ошибки — пофайлово.
5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
карточках каталога, результатах поиска и в шапке страницы инструмента; список
slug в localStorage. Избранное показывается отдельной секцией сверху каталога
и поднимается в выдаче поиска (бонус к popularity при скоринге).
6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
— оценка M/L. Решение и продуктовое обоснование: `docs/plan-seo.md` §0 и
задача **S1f**; почему селектор не заменяет существующие convert-страницы —
`docs/plan-seo.md` §5. Механическая часть уже есть: `Tool.output`
(`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в
`SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось
сверх S1f:
- экспорт с лимитом размера → «сжатие до целевого КБ»: для lossy это quality
в селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой
download-флоу.
7. **Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна
overlay-механика: второй источник изображения (getOverlay/store), параметры
scale/opacity/position/margin/aspect. Решается вместе с вопросом, как второй
источник картинки вписывается в schema-driven preview (`SchemaToolView`).
8. **`png-info` (детальная информация о PNG)** — оценка M/L. Отдельный райз:
exif-теги, редактирование, структурированный вывод; нужен свой отдельный
случай в UI, не «ещё один text/verdict». Возможно нужна вообще отдельная
страница, несовместимая с другими инструментами: 1) она работает не с
изображением, а с метаданными из файла, 2) она не может быть частью цепочки,
т.к. результат не является изображением, 3) если надо изменить теги, никакого
форматирования и изменения в изображение вносить не надо, только в
метаинформацию.
9. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
reverse-area) — ждут UI выделения области на превью. Вне очереди.
10. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
— механизм «результат = набор файлов» (1 → many) уже есть
(`docs/architecture.md`, раздел 7), первый потребитель —
`split-into-parts-png`. Остальные подключаются по мере нужды.
11. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
решение о формате.
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital).
13. **Контраст текста на акцентных кнопках — частично закрыт**. Тона
`--color-main`/`--color-accent` подняты под текущий порог `palette.test.ts`;
targeted-тест проходит. Light-тема всё ещё ниже AA 4.5 для обычного текста,
а `--color-danger` в light-теме на границе: после завершения редизайна
вернуть порог 4.5 и перепроверить оба токена.
14. **Отображение маски для инструментов (`runMask`)** — придумать и реализовать
процесс показа маски результата. **Контекст:** удалённый UI-v1 умел
показывать ч/б превью-маску «что будет вырезано» отдельным полем; при
переходе на единый `run(ctx)` этого поля не осталось — executor и
schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:**
дать каждому `run` возможность вернуть (или инструмент — объявить) `runMask`
— ч/б изображение маски изменений либо иной подход (отдельное поле `mask` +
функция маски, второй результат, overlay). Оценка — на усмотрение при
проработке (S/M). Пока живём без неё. **Идея 2:** каждый `run` возвращает
два изображения — для отображения (показывает маску или сам результат) и для
передачи в следующий инструмент (всегда результат). Оценка — на усмотрение
при проработке (S/M). Пока живём без неё. Первые кандидаты на маску:
`remove-color-from-png`, `remove-background-png`.
15. **Набор файлов в пайплайне: 1 → many не должен быть терминальным** — оценка
L. **Сейчас** `execute` принимает один `source: PixelImage | undefined` и
возвращает один `ToolResult`, а `SchemaToolView` раскладывает вывод в
`result` / `fileResult` / `textResult` по `resultKind` — поэтому шаг с
`result: "files"` упирается в конец цепочки. **Направление:** следующий
инструмент применяется **ко всем картинкам** предыдущего результата
(fan-out), а many → 1 (вход = архив или несколько файлов) — обратная сторона
того же контракта. Терминальными остаются только результаты, которые не
картинка (text, verdict). Что обдумать:
- где проходит граница: один вызов `run(ctx)` на файл (имя части склеивается
в `FileResult.files`) или батч целиком на стороне исполнителя;
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
прогресс и отмена;
- частичные ошибки: один битый файл не должен ронять весь шаг — нужен
per-file отчёт, а не один `ToolError` на шаг;
- имена частей на каждом шаге и Download для набора (сейчас zip по
`page.slug`, `SchemaToolView.download()`);
- обратная сторона: распаковка zip в браузере, порядок файлов, поведение
many → 1 → many в одной цепочке. Зависит от идеи 10 (остальные потребители
1 → many) и идеи 4 (batch по архиву), `docs/roadmap.md` фаза 8
«batch-страница».
16. **Главная должна отличаться от каталога** — оценка S. Сейчас
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
Замысел: главная — крупнее и с более простым поиском, без категорий; каталог
остаётся группировкой с фильтрами. Детектор дублирования разметки
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
ловится на ревью.
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
plurals есть все формы, для которых нужен перевод, `other` никогда не
пустой, число подставляется в текст без склейки
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "easy-png-tools",
"version": "0.2.0",
"version": "0.3.0",
"description": "",
"main": "index.js",
"scripts": {
+12
View File
@@ -56,12 +56,24 @@ const decodeContracts: readonly DecodeContract[] = [
{ file: sixteenBitPng, rejectedIn: [] },
];
// На этих файлах createImageBitmap в webkit не завершается: ни результата, ни
// ошибки — контракт «accept или reject» проверить нельзя.
const webkitHangs = new Set([
"truncated-header.png",
"crc-bad-ancillary.png",
"palette-256.png",
]);
test.describe("PNG special fixtures", () => {
for (const { file, rejectedIn } of decodeContracts) {
test(`${file.name}: matches the engine decode contract`, async ({
browserName,
page,
}) => {
test.fixme(
browserName === "webkit" && webkitHangs.has(file.name),
"webkit createImageBitmap зависает на этом файле",
);
if (rejectedIn.includes(browserName)) {
await expectRejected(page, file);
} else {
+4 -1
View File
@@ -295,7 +295,10 @@ test("chain: remove step returns to a single tool", async ({ page }) => {
exact: true,
});
await expect(stepTwo).toBeVisible();
await page.getByRole("button", { name: "Remove step" }).last().click();
// Клик по remove именно в карточке Grayscale: порядок шагов зависит от
// тайминга рендера и на разных движках может отличаться.
const card = page.getByRole("article").filter({ hasText: "Grayscale PNG" });
await card.getByLabel("Remove step").click();
await expect(stepTwo).toHaveCount(0);
expectNoErrors(sink);
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite dev",
+6
View File
@@ -44,6 +44,12 @@ export default defineConfig({
{
name: "webkit",
testMatch: browserContractTests,
// webkit под Windows заметно медленнее и под нагрузкой случайно
// превышает любые таймауты (каждый прогон — разные тесты): запас по
// таймаутам + retries против флака.
timeout: 120_000,
expect: { timeout: 30_000 },
retries: 2,
use: {
browserName: "webkit",
viewport: { width: 1440, height: 900 },
+1
View File
@@ -122,6 +122,7 @@
--color-main-tint: hct(from var(--brand-main) h 65 55);
--color-main-soft: color-mix(in srgb, var(--color-main) 12%, transparent);
--color-scrim: color-mix(in srgb, var(--color-background) 65%, transparent);
--color-accent-tint: hct(from var(--brand-alt) h 65 55);
--color-success-tint: hct(160 65 55);
--color-warning-tint: hct(80 65 55);
@@ -35,13 +35,17 @@
<article
class="step-card"
{draggable}
ondragstart={(e) => ondragstart?.(e)}
ondragover={(e) => ondragover?.(e)}
ondrop={(e) => ondrop?.(e)}
ondragend={() => ondragend?.()}
>
<header class="step-head">
<header
class="step-head"
role="button"
tabindex="0"
{draggable}
ondragstart={(e) => ondragstart?.(e)}
ondragend={() => ondragend?.()}
>
<span class="step-index">{index.toString().padStart(2, "0")}</span>
<GripVertical size={16} class="drag" aria-hidden="true" />
<div class="step-heading-text">
@@ -13,11 +13,25 @@
label: string;
value: unknown;
spec: FieldSpec;
/** Размеры входа шага: потолок осей для maxFromSource-полей. */
sourceDims?: Dimension;
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
}
let { label, value, spec, onchange }: Props = $props();
let {
label,
value,
spec,
sourceDims = undefined,
onchange,
}: Props = $props();
const sp = $derived(spec as DimensionSpec);
const maxW = $derived(
sp.maxFromSource && sourceDims ? sourceDims.width : sp.max,
);
const maxH = $derived(
sp.maxFromSource && sourceDims ? sourceDims.height : sp.max,
);
const current = $derived.by(() => {
const v = value as Partial<Dimension> | undefined;
return {
@@ -49,7 +63,7 @@
<input
type="number"
min={sp.min}
max={sp.max}
max={maxW}
value={current.width}
oninput={(e) =>
setAxis("width", Number((e.target as HTMLInputElement).value))}
@@ -60,7 +74,7 @@
<input
type="number"
min={sp.min}
max={sp.max}
max={maxH}
value={current.height}
oninput={(e) =>
setAxis("height", Number((e.target as HTMLInputElement).value))}
@@ -2,6 +2,7 @@
import type { InputMode, ResultKind } from "$lib/registry";
import { RefreshCw } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
import Badge from "$lib/components/ui/Badge.svelte";
import type { Snippet } from "svelte";
interface Props {
@@ -10,6 +11,10 @@
children: Snippet;
loading?: boolean;
parts?: number;
partsDims?: string;
dims?: string;
note?: string;
noteTone?: "warning" | "info";
}
let {
label,
@@ -17,20 +22,32 @@
children,
loading = false,
parts = undefined,
partsDims = undefined,
dims = undefined,
note = undefined,
noteTone = "warning",
}: Props = $props();
</script>
<figure class="tile">
<figcaption>
<span>
{label}
{#if parts !== undefined}
<span class="count">{parts} {t("resultCard.parts")}</span>
{/if}
<span class="tile-label">
<span
>{label}{#if parts !== undefined}<span class="dims"
>: {parts}
{t("resultCard.parts")}{#if partsDims}
· {partsDims}{/if}</span
>{:else if dims}<span class="dims">: {dims}</span>{/if}</span
>
{#if loading}
<RefreshCw class="rotating" size="12" />
{/if}
</span>
<span class="tile-note">
{#if note}
<Badge tone={noteTone} label={note} />
{/if}
</span>
</figcaption>
<div class="canvas" class:checker={mode === "image"}>
{@render children()}
@@ -41,7 +58,10 @@
.tile figcaption {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-m);
/* Резерв под бейдж: строка не прыгает, когда заметка появляется. */
min-height: var(--space-xxl);
margin-bottom: var(--space-m);
font: var(--font-size-s) var(--font-mono);
text-transform: uppercase;
@@ -50,8 +70,18 @@
animation: rotate var(--duration-l) linear infinite;
}
}
.count {
margin-left: var(--space-m);
.tile-label {
display: inline-flex;
align-items: center;
gap: var(--space-m);
}
/* Место под заметки результата: бейджи-предупреждения. */
.tile-note {
display: inline-flex;
align-items: center;
gap: var(--space-m);
}
.dims {
color: var(--color-main);
}
.canvas {
@@ -69,7 +99,7 @@
width: auto;
max-width: 100%;
height: auto;
max-height: 100vh;
max-height: 60vh;
object-fit: contain;
display: block;
}
@@ -1,10 +1,11 @@
<script lang="ts">
import type {
Dimension,
FieldSpec,
FieldSpecKind,
ToolSchema,
} from "$lib/registry-schema";
import { resolveLayoutGroups } from "$lib/registry-schema";
import { effectiveMax, resolveLayoutGroups } from "$lib/registry-schema";
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { RotateCcw } from "@lucide/svelte";
@@ -30,6 +31,7 @@
spec: FieldSpec;
toolId: string;
fieldId: string;
sourceDims?: Dimension;
onchange?: (value: unknown, axis?: "width" | "height" | "both") => void;
}
@@ -54,6 +56,8 @@
schema: ToolSchema<Record<string, unknown>>;
values: Record<string, unknown>;
toolId: string;
/** Размеры входа шага: для maxFromSource-полей (crop и т.п.). */
sourceDims?: Dimension;
onchange: (
id: string,
value: unknown,
@@ -61,9 +65,23 @@
) => void;
onreset: () => void;
}
let { schema, values, toolId, onchange, onreset }: Props = $props();
let {
schema,
values,
toolId,
sourceDims = undefined,
onchange,
onreset,
}: Props = $props();
const layoutGroups = $derived(resolveLayoutGroups(schema));
function effectiveSpec(spec: FieldSpec): FieldSpec {
if (sourceDims && spec.kind === "number" && spec.maxFromSource) {
return { ...spec, max: effectiveMax(spec, sourceDims) };
}
return spec;
}
</script>
{#each layoutGroups as group (group.key)}
@@ -81,9 +99,10 @@
<Control
label={fieldLabel(schema.fields[id], id)}
value={values[id]}
spec={schema.fields[id].spec}
spec={effectiveSpec(schema.fields[id].spec)}
{toolId}
fieldId={id}
{sourceDims}
onchange={(v, axis) => onchange(id, v, axis)}
/>
{/each}
@@ -1,13 +1,17 @@
<script lang="ts">
import MetaList from "$lib/components/display/MetaList.svelte";
import { buildSchemaPreviewModel } from "./schema-preview-model";
import { hasPreviewResult } from "./schema-preview-model";
import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import type { PixelImage } from "$lib/core/types";
import type { OutputMime } from "$lib/core/io";
import { t } from "$lib/i18n/t";
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
import type {
FileResult,
InputMode,
ResultKind,
ResultNote,
} from "$lib/registry";
interface Props {
inputMode: InputMode;
@@ -15,6 +19,7 @@
toolId: string;
source: PixelImage | null;
result: PixelImage | null;
resultNote?: ResultNote | null;
fileResult?: FileResult | null;
textSource?: string;
textResult?: string | null;
@@ -39,6 +44,7 @@
toolId,
source,
result,
resultNote = null,
fileResult = null,
textSource = "",
textResult = null,
@@ -58,11 +64,8 @@
onquality,
}: Props = $props();
const model = $derived(
buildSchemaPreviewModel(
{ inputMode, resultKind, source, result, fileResult, textResult },
t,
),
const hasResult = $derived(
hasPreviewResult({ inputMode, resultKind, result, fileResult, textResult }),
);
</script>
@@ -71,7 +74,7 @@
<SchemaActions
{inputMode}
{resultKind}
canDownload={model.hasResult}
canDownload={hasResult}
{running}
{format}
{quality}
@@ -100,6 +103,7 @@
<SchemaResultTile
{resultKind}
{result}
{resultNote}
{fileResult}
{textResult}
{textVars}
@@ -110,15 +114,6 @@
/>
</div>
<div class="meta">
<MetaList
items={[
{ caption: t("sourceCard.source"), value: model.sourceValue },
{ caption: t("resultCard.result"), value: model.resultValue },
]}
/>
</div>
<style>
.panel-head {
display: flex;
@@ -145,9 +140,6 @@
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
.meta {
margin-top: var(--space-l);
}
@media (--bp-tablet) {
.pair {
grid-template-columns: 1fr;
@@ -2,7 +2,7 @@
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { FileResult, ResultKind } from "$lib/registry";
import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
import { Check } from "@lucide/svelte";
import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./PreviewTile.svelte";
@@ -10,6 +10,7 @@
interface Props {
resultKind?: ResultKind;
result: PixelImage | null;
resultNote?: ResultNote | null;
fileResult?: FileResult | null;
textResult?: string | null;
textVars?: Record<string, string | number>;
@@ -21,6 +22,7 @@
let {
resultKind = "image",
result,
resultNote = null,
fileResult = null,
textResult = null,
textVars = undefined,
@@ -31,6 +33,11 @@
}: Props = $props();
const resultUrl = $derived(result ? toDataUrl(result) : null);
const resultDims = $derived(
resultKind === "image" && result
? `${result.width} × ${result.height}`
: undefined,
);
const partUrls = $derived(
resultKind === "files" && fileResult
? fileResult.files.map((file) => ({
@@ -43,6 +50,12 @@
let parts = $derived(
resultKind === "files" && fileResult ? fileResult.files.length : undefined,
);
// Размеры одинаковы у всех частей (канвас дополняется) — показываем один раз.
let partsDims = $derived(
resultKind === "files" && fileResult && fileResult.files.length > 0
? `${fileResult.files[0].image.width} × ${fileResult.files[0].image.height}`
: undefined,
);
</script>
<PreviewTile
@@ -50,8 +63,16 @@
viewMode={resultKind}
loading={running}
{parts}
{partsDims}
dims={resultDims}
note={resultNote ? t(resultNote.key) : undefined}
noteTone={resultNote?.tone ?? "warning"}
>
<div class="canvas" class:checker={resultKind === "image"}>
<div
class="canvas"
class:checker={resultKind === "image"}
class:files={resultKind === "files"}
>
{#if resultKind === "text" && textResult}
<div class="text-result-wrap">
<SchemaTextResult
@@ -102,6 +123,10 @@
display: flex;
align-items: center;
justify-content: center;
/* Вложен в PreviewTile.canvas: без растяжения сжимался по контенту и
ломал parts-grid в одну колонку. */
width: 100%;
align-self: stretch;
min-height: clamp(var(--space-brand), 30vh, 60vh);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
@@ -113,7 +138,7 @@
width: auto;
max-width: 100%;
height: auto;
max-height: 100vh;
max-height: 60vh;
object-fit: contain;
display: block;
}
@@ -124,6 +149,11 @@
)
50% / 28px 28px;
}
/* Сетка частей: без потолка 1024 плитки растягивали страницу бесконечно. */
.canvas.files {
max-height: 60vh;
align-items: flex-start;
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
@@ -27,8 +27,32 @@
}: Props = $props();
const sourceUrl = $derived(source ? toDataUrl(source) : null);
const sourceDims = $derived(
source ? `${source.width} × ${source.height}` : undefined,
);
let dragging = $state(false);
let input = $state<HTMLInputElement | null>(null);
// Подсказка замены: показываем на ховер и гасим через 10с до нового захода.
let hintVisible = $state(false);
let hintTimer: ReturnType<typeof setTimeout> | null = null;
function showHint() {
hintVisible = true;
if (hintTimer) clearTimeout(hintTimer);
hintTimer = setTimeout(() => {
hintVisible = false;
hintTimer = null;
}, 3_000);
}
function hideHint() {
hintVisible = false;
if (hintTimer) {
clearTimeout(hintTimer);
hintTimer = null;
}
}
function onDrop(e: DragEvent) {
e.preventDefault();
@@ -36,9 +60,13 @@
const file = e.dataTransfer?.files?.[0];
if (file) onupload?.(file);
}
function open() {
input?.click();
}
</script>
<PreviewTile label={t("sourceCard.source")} viewMode={mode}>
<PreviewTile label={t("sourceCard.source")} viewMode={mode} dims={sourceDims}>
{#if mode === "text"}
<div class="text-source-wrap">
<SchemaTextSource
@@ -52,15 +80,38 @@
<div
class="tile-drop"
class:dragging
role="region"
aria-label={t("dropZone.overlayDefault")}
class:hint={hintVisible}
role="button"
tabindex="0"
aria-label={t("sourceCard.replaceImage")}
onclick={open}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
}}
ondragover={(e) => {
e.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
onmouseenter={showHint}
onmouseleave={hideHint}
onfocus={showHint}
onblur={hideHint}
>
<input
bind:this={input}
type="file"
accept="image/*"
hidden
onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) onupload?.(file);
}}
/>
<img
data-testid="source-image"
src={sourceUrl}
@@ -68,6 +119,8 @@
/>
{#if dragging}
<span class="drop-hint">{t("dropZone.overlayDefault")}</span>
{:else}
<span class="hover-hint">{t("sourceCard.replaceHint")}</span>
{/if}
</div>
{:else if mode === "image"}
@@ -114,4 +167,38 @@
text-align: center;
pointer-events: none;
}
.tile-drop {
cursor: pointer;
}
.tile-drop::before {
content: "";
position: absolute;
inset: 0;
background: var(--color-scrim);
opacity: 0;
transition: opacity var(--duration-s) ease;
pointer-events: none;
}
.tile-drop.hint::before {
opacity: 1;
}
.tile-drop .hover-hint {
position: absolute;
inset: 25%;
display: grid;
place-items: center;
padding: var(--space-l);
border: var(--size-border-thick) dashed var(--color-main);
border-radius: var(--radius-m);
background: var(--color-panel);
color: var(--color-main);
font: 600 var(--font-size-l) var(--font-mono);
text-align: center;
opacity: 0;
transition: opacity var(--duration-s) ease;
pointer-events: none;
}
.tile-drop.hint .hover-hint {
opacity: 1;
}
</style>
@@ -40,6 +40,7 @@
} from "$lib/registry";
import {
applySourceDefaults,
clampSourceAwareMaxes,
defaultSchemaParams,
withAspectLock,
type Dimension,
@@ -105,6 +106,11 @@
// он — единый источник качества; dropdown редактирует его же.
const qualityParamId = $derived(lastTool.output?.qualityParamId);
const lastParams = $derived(steps.at(-1)?.params);
const resultNote = $derived(
result && lastTool.resultNote
? lastTool.resultNote(lastParams ?? {}, result)
: null,
);
const currentQuality = $derived.by(() => {
const setting = outputFormatByMime(format).settings?.quality;
if (!setting) return undefined;
@@ -205,7 +211,8 @@
}
}
}
steps = steps.with(index, { ...step, params: next });
const clamped = dims ? clampSourceAwareMaxes(stepSchema, next, dims) : next;
steps = steps.with(index, { ...step, params: clamped });
}
function resetStep(index: number) {
@@ -249,12 +256,16 @@
if (first && firstSchema) {
steps = steps.with(0, {
...first,
params: applySourceDefaults(
params: clampSourceAwareMaxes(
firstSchema,
applySourceDefaults(
firstSchema,
first.params,
{ source: decoded },
touchedFor(first.key),
),
decoded,
),
});
}
} catch (e) {
@@ -309,7 +320,7 @@
const base = toDisplayError(e);
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
throw new Error(
t("chain.stepError", {
t("toolPage.stepError", {
n: i + 1,
title: stepTitle(stepTool.id),
msg,
@@ -468,6 +479,7 @@
schema={toolSchemaOf(step)}
values={step.params}
toolId={step.id}
sourceDims={i === 0 ? (source ?? undefined) : stepDims[i - 1]}
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
onreset={() => resetStep(i)}
/>
@@ -483,6 +495,7 @@
toolId={lastTool.id}
{source}
{result}
{resultNote}
{fileResult}
{textSource}
{textResult}
@@ -1,107 +1,47 @@
import { describe, expect, it } from "vitest";
import { makeImage } from "../../core/test-helpers";
import {
buildSchemaPreviewModel,
type PreviewTranslate,
hasPreviewResult,
type SchemaPreviewModelInput,
} from "./schema-preview-model";
const image = makeImage(2, 3, [
[1, 2, 3, 255],
[4, 5, 6, 255],
[7, 8, 9, 255],
[10, 11, 12, 255],
[13, 14, 15, 255],
[16, 17, 18, 255],
]);
const image = { width: 2, height: 3, data: new Uint8ClampedArray(24) };
const translate: PreviewTranslate = (key) => {
if (key === "resultCard.parts") return "parts";
if (key === "textInput.heading") return "Text";
return key;
};
function input(
overrides: Partial<Parameters<typeof buildSchemaPreviewModel>[0]> = {},
) {
function input(over: Partial<SchemaPreviewModelInput> = {}) {
return {
inputMode: "image" as const,
resultKind: "image" as const,
source: null,
inputMode: "image",
resultKind: "image",
result: null,
fileResult: null,
textResult: null,
...overrides,
};
...over,
} satisfies SchemaPreviewModelInput;
}
describe("buildSchemaPreviewModel", () => {
it("formats source values for every input mode", () => {
expect(
buildSchemaPreviewModel(input({ inputMode: "none" }), translate),
).toMatchObject({ sourceValue: "—" });
expect(
buildSchemaPreviewModel(input({ inputMode: "text" }), translate),
).toMatchObject({ sourceValue: "Text" });
expect(
buildSchemaPreviewModel(
input({ inputMode: "image", source: image }),
translate,
),
).toMatchObject({ sourceValue: "2 × 3 px" });
describe("hasPreviewResult", () => {
it("image: результат только при наличии картинки", () => {
expect(hasPreviewResult(input({ result: image }))).toBe(true);
expect(hasPreviewResult(input())).toBe(false);
});
it("formats image results and enables download only with a result", () => {
expect(
buildSchemaPreviewModel(input({ result: image }), translate),
).toEqual({
sourceValue: "—",
resultValue: "2 × 3 px",
hasResult: true,
});
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
resultValue: "—",
hasResult: false,
});
});
it("formats file results as ZIP and rejects empty file results", () => {
it("files: результат только при непустом наборе", () => {
const fileResult = { files: [{ name: "part.png", image }] };
expect(hasPreviewResult(input({ resultKind: "files", fileResult }))).toBe(
true,
);
expect(
buildSchemaPreviewModel(
input({ resultKind: "files", fileResult }),
translate,
),
).toEqual({
sourceValue: "—",
resultValue: "1 parts",
hasResult: true,
});
expect(
buildSchemaPreviewModel(
hasPreviewResult(
input({ resultKind: "files", fileResult: { files: [] } }),
translate,
),
).toMatchObject({ resultValue: "—", hasResult: false });
).toBe(false);
});
it.each(["text", "verdict"] as const)(
"uses text result presence for %s output",
"%s: результат по наличию текста",
(resultKind) => {
expect(
buildSchemaPreviewModel(
input({ resultKind, textResult: "done" }),
translate,
),
).toMatchObject({
resultValue: "Text",
hasResult: true,
});
expect(
buildSchemaPreviewModel(input({ resultKind }), translate),
).toMatchObject({
resultValue: "—",
hasResult: false,
});
expect(hasPreviewResult(input({ resultKind, textResult: "done" }))).toBe(
true,
);
expect(hasPreviewResult(input({ resultKind }))).toBe(false);
},
);
});
@@ -1,58 +1,20 @@
import type { PixelImage } from "$lib/core/types";
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
export type PreviewTranslate = (key: string) => string;
export type SchemaPreviewModelInput = {
inputMode: InputMode;
resultKind: ResultKind;
source: PixelImage | null;
result: PixelImage | null;
fileResult: FileResult | null;
textResult: string | null;
};
export type SchemaPreviewModel = {
sourceValue: string;
resultValue: string;
hasResult: boolean;
};
export function buildSchemaPreviewModel(
input: SchemaPreviewModelInput,
translate: PreviewTranslate,
): SchemaPreviewModel {
const { inputMode, resultKind, source, result, fileResult, textResult } =
input;
const sourceValue =
inputMode === "none"
? "—"
: inputMode === "text"
? translate("textInput.heading")
: source
? `${source.width} × ${source.height} px`
: "—";
const hasFiles = Boolean(fileResult && fileResult.files.length > 0);
const resultValue =
resultKind === "image"
? result
? `${result.width} × ${result.height} px`
: "—"
: resultKind === "files"
? hasFiles
? `${fileResult?.files.length} ${translate("resultCard.parts")}`
: "—"
: textResult
? translate("textInput.heading")
: "—";
return {
sourceValue,
resultValue,
hasResult:
resultKind === "image"
? Boolean(result)
: resultKind === "files"
? hasFiles
: Boolean(textResult),
};
/** Можно ли скачать результат: он есть и он не пустой. */
export function hasPreviewResult(input: SchemaPreviewModelInput): boolean {
const { resultKind, result, fileResult, textResult } = input;
if (resultKind === "image") return Boolean(result);
if (resultKind === "files") {
return Boolean(fileResult && fileResult.files.length > 0);
}
return Boolean(textResult);
}
+1 -1
View File
@@ -61,7 +61,7 @@
display: flex;
align-items: center;
gap: var(--space-s);
line-height: normal;
line-height: 1;
font-family: var(--font-mono);
font-size: var(--font-size-s);
font-weight: bold;
+4 -2
View File
@@ -127,7 +127,7 @@ export interface TextToImageOptions {
bold: boolean;
color: string;
backgroundColor: string;
transparentBg: boolean;
bgOpacity: number;
padding: number;
maxTextWidth?: number;
}
@@ -148,9 +148,11 @@ export function renderTextToImage(o: TextToImageOptions): PixelImage {
const h = Math.max(1, Math.ceil(lineH + o.padding * 2));
const { canvas, ctx } = ctx2d(w, h);
if (!o.transparentBg) {
if (o.bgOpacity > 0) {
ctx.globalAlpha = Math.min(100, Math.max(0, o.bgOpacity)) / 100;
ctx.fillStyle = o.backgroundColor;
ctx.fillRect(0, 0, w, h);
ctx.globalAlpha = 1;
}
ctx.font = fontString(o.fontSize, o.font, o.bold);
ctx.fillStyle = o.color;
+9 -3
View File
@@ -29,16 +29,22 @@ describe("randomColorBlocks", () => {
describe("drawGrid", () => {
it("линии на пересечениях непрозрачны, фон прозрачен", () => {
const out = drawGrid(100, 100, 4, 4, 2, "#000000", true);
const out = drawGrid(100, 100, 4, 4, 2, "#000000", 0);
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);
it("белый непрозрачный фон при bgOpacity=100", () => {
const out = drawGrid(20, 20, 2, 2, 1, "#000000", 100);
const mid = (5 * 20 + 5) * 4;
expect(out.data[mid]).toBe(255);
});
it("полупрозрачный фон при bgOpacity=50", () => {
const out = drawGrid(20, 20, 2, 2, 1, "#000000", 50);
const mid = (5 * 20 + 5) * 4;
expect(out.data[mid + 3]).toBe(128);
});
});
+13 -3
View File
@@ -79,7 +79,7 @@ function lineMask(
return mask;
}
/** Сетка линий на прозрачном или белом фоне. */
/** Сетка линий на белом фоне заданной непрозрачности (0 — прозрачный). */
export function drawGrid(
width: number,
height: number,
@@ -87,10 +87,20 @@ export function drawGrid(
rows: number,
lineWidth: number,
colorHex: string,
transparentBg: boolean,
bgOpacity: number,
): PixelImage {
const out = createPixelImage(width, height);
if (!transparentBg) out.data.fill(255);
const bgAlpha = Math.round(
(Math.max(0, Math.min(100, bgOpacity)) / 100) * 255,
);
if (bgAlpha > 0) {
for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = 255;
out.data[i + 1] = 255;
out.data[i + 2] = 255;
out.data[i + 3] = bgAlpha;
}
}
const m = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
const r = m ? parseInt(m[1].slice(0, 2), 16) : 0;
+3 -2
View File
@@ -9,19 +9,20 @@ export function expandCanvas(
right: number,
bottom: number,
backgroundHex?: string,
backgroundAlpha = 255,
): PixelImage {
const l = Math.max(0, Math.trunc(left));
const t = Math.max(0, Math.trunc(top));
const r = Math.max(0, Math.trunc(right));
const b = Math.max(0, Math.trunc(bottom));
const out = createPixelImage(img.width + l + r, img.height + t + b);
if (backgroundHex !== undefined) {
if (backgroundHex !== undefined && backgroundAlpha > 0) {
const [cr, cg, cb] = parseHex(backgroundHex);
for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = cr;
out.data[i + 1] = cg;
out.data[i + 2] = cb;
out.data[i + 3] = 255;
out.data[i + 3] = backgroundAlpha;
}
}
for (let y = 0; y < img.height; y++) {
+8 -5
View File
@@ -59,6 +59,7 @@ export const en: Dict = {
sourceCard: {
source: "Source",
replaceImage: "Replace image",
replaceHint: "Click to upload an image",
chooseImage: "choose an image",
noSource: "no source — configure the parameters",
alt: "Source image",
@@ -75,6 +76,9 @@ export const en: Dict = {
result: "Result",
previewPanel: "Preview",
parts: "parts",
clamped: "clamped to image bounds",
aspectAdjusted: "size adjusted to keep aspect",
grown: "grew beyond requested size — image is larger",
alt: "Result image",
},
paramsCard: {
@@ -173,7 +177,8 @@ export const en: Dict = {
radiusInt: "Radius must be a non-negative integer",
kernelSize: "Kernel does not match the image dimensions",
sizeInt: "Width and height must be integers ≥ 1",
cropBounds: "Crop area does not intersect the image",
cropBounds:
"Crop area doesn't fit: offset plus size exceeds the image bounds",
noCanvasCtx: "Canvas 2D context is unavailable in this environment",
badBase64: "Expected a base64 string or a data-uri of an image",
qualityRange: "quality must be within 0..1",
@@ -196,9 +201,9 @@ export const en: Dict = {
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",
cropSize: "Crop width and height must be at least 1 px",
sizePositive: "Dimensions must be positive and finite",
tooManyParts: "Too many parts ({count}). Maximum is 1000.",
tooManyParts: "Too many parts ({count}). The limit is {max}.",
pageUnknown: 'No page is registered under "{slug}".',
},
fields: {
@@ -297,8 +302,6 @@ export const en: Dict = {
textStyle: "Text style",
thickness: "Thickness",
threshold: "Threshold",
transparent: "Transparent",
transparentBg: "Transparent bg",
waves: "Waves",
width: "Width",
x: "X",
+13 -10
View File
@@ -60,6 +60,7 @@ export const ru: Dict = {
sourceCard: {
source: "Источник",
replaceImage: "Заменить изображение",
replaceHint: "Кликните, чтобы загрузить изображение",
chooseImage: "выберите изображение",
noSource: "нет источника — настройте параметры",
alt: "Исходное изображение",
@@ -76,6 +77,9 @@ export const ru: Dict = {
result: "Результат",
previewPanel: "Предпросмотр",
parts: "частей",
clamped: "уменьшено до границ изображения",
aspectAdjusted: "размер скорректирован по пропорциям",
grown: "больше запрошенного — картинка крупнее",
alt: "Изображение-результат",
},
paramsCard: {
@@ -174,7 +178,8 @@ export const ru: Dict = {
radiusInt: "Радиус должен быть целым неотрицательным числом",
kernelSize: "Ядро не совпадает с изображением по размеру",
sizeInt: "Ширина и высота должны быть целыми числами ≥ 1",
cropBounds: "Область обрезки не пересекает изображение",
cropBounds:
"Область не помещается: смещение + размер превышают границы изображения",
noCanvasCtx: "Canvas 2D context недоступен в этом окружении",
badBase64: "Ожидается base64-строка или data-uri изображения",
qualityRange: "quality должно быть в диапазоне 0..1",
@@ -197,9 +202,9 @@ export const ru: Dict = {
paramString: 'Параметр "{id}" должен быть строкой',
paramBool: 'Параметр "{id}" должен быть значением флажка',
resizeSize: "Ширина и/или высота должны быть положительными",
cropSize: "Ширина и высота области обрезки должны быть положительными",
cropSize: "Ширина и высота области должны быть не меньше 1 px",
sizePositive: "Размеры должны быть положительными и конечными",
tooManyParts: "Слишком много частей ({count}). Ограничение — 1000.",
tooManyParts: "Слишком много частей ({count}). Ограничение — {max}.",
pageUnknown: "Страницы «{slug}» нет в каталоге.",
},
fields: {
@@ -298,8 +303,6 @@ export const ru: Dict = {
textStyle: "Стиль текста",
thickness: "Толщина",
threshold: "Порог",
transparent: "Прозрачный",
transparentBg: "Прозрачный фон",
waves: "Волны",
width: "Ширина",
x: "X",
@@ -971,7 +974,7 @@ export const ru: Dict = {
"add-padding": {
params: {
padding: "Поля, px",
transparent: "Прозрачные поля",
opacity: "Непрозрачность полей, %",
color: "Цвет полей",
},
},
@@ -982,7 +985,7 @@ export const ru: Dict = {
params: {
width: "Ширина полотна",
height: "Высота полотна",
transparent: "Прозрачный фон",
opacity: "Непрозрачность фона, %",
color: "Цвет фона",
},
},
@@ -1112,7 +1115,7 @@ export const ru: Dict = {
params: {
width: "Ширина",
height: "Высота",
transparent: "Прозрачный",
opacity: "Непрозрачность",
color: "Цвет",
},
},
@@ -1351,7 +1354,7 @@ export const ru: Dict = {
font: "Шрифт",
bold: "Жирный",
color: "Цвет текста",
transparentBg: "Прозрачный фон",
bgOpacity: "Непрозрачность фона, %",
backgroundColor: "Цвет фона",
padding: "Отступ, px",
},
@@ -1396,7 +1399,7 @@ export const ru: Dict = {
rows: "Строки",
lineWidth: "Толщина линий, px",
color: "Цвет линий",
transparentBg: "Прозрачный фон",
bgOpacity: "Непрозрачность фона, %",
},
},
quantize: {
+102
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
applySourceDefaults,
clampSourceAwareMaxes,
defaultSchemaParams,
field,
resolveLayoutGroups,
@@ -89,6 +90,107 @@ describe("registry-schema: withAspectLock", () => {
});
});
describe("registry-schema: maxFromSource", () => {
const cropLike = toolSchema<{
x: number;
size: Dimension;
}>({
x: field.number({ min: 0, max: 20000, default: 0, maxFromSource: "width" }),
size: field.dimension({
min: 1,
max: 20000,
width: 1,
height: 1,
maxFromSource: true,
}),
});
const img = { width: 832, height: 1216 };
it("number клампится к размеру исходника, а не к статическому max", () => {
const s = sanitizeSchemaParams(cropLike, { x: 5000 }, { source: img });
expect(s.x).toBe(832);
});
it("dimension клампится по осям исходника", () => {
const s = sanitizeSchemaParams(
cropLike,
{ size: { width: 5000, height: 5000 } },
{ source: img },
);
expect(s.size).toEqual({ width: 832, height: 1216 });
});
it("без исходника — статический max", () => {
const s = sanitizeSchemaParams(cropLike, {
x: 5000,
size: { width: 5000, height: 5000 },
});
expect(s.x).toBe(5000);
expect(s.size).toEqual({ width: 5000, height: 5000 });
});
it("maxMinus: потолок = исходник − константа (offset до size−1)", () => {
const offsetLike = toolSchema<{ x: number }>({
x: field.number({
min: 0,
max: 20000,
default: 0,
maxFromSource: "width",
maxMinus: 1,
}),
});
const s = sanitizeSchemaParams(
offsetLike,
{ x: 5000 },
{ source: { width: 832, height: 1216 } },
);
expect(s.x).toBe(831);
});
it("effectiveMax: без maxFromSource и без исходника — статический max", () => {
const plain = toolSchema<{ a: number; b: number }>({
a: field.number({ min: 0, max: 100, default: 0 }),
b: field.number({
min: 0,
max: 20000,
default: 0,
maxFromSource: "width",
}),
});
const s = sanitizeSchemaParams(
plain,
{ a: 500, b: 5000 },
{ source: { width: 832, height: 1216 } },
);
expect(s.a).toBe(100);
const noSource = sanitizeSchemaParams(plain, { b: 5000 });
expect(noSource.b).toBe(5000);
});
it("clampSourceAwareMaxes: без исходника и без изменений — те же params", () => {
const noSource = clampSourceAwareMaxes(cropLike, { x: 5000 });
expect(noSource).toEqual({ x: 5000 });
const untouched = clampSourceAwareMaxes(
cropLike,
{ x: "мусор", size: { width: 100, height: 100 } },
img,
);
expect(untouched).toEqual({
x: "мусор",
size: { width: 100, height: 100 },
});
});
it("clampSourceAwareMaxes: dimension с мусором пропускается", () => {
const out = clampSourceAwareMaxes(
cropLike,
{ size: "не-объект" as unknown as Dimension },
img,
);
expect(out).toEqual({ size: "не-объект" });
});
});
describe("registry-schema: source-aware dimension defaults", () => {
it("uses static fallback before a source exists", () => {
expect(defaultSchemaParams(sourceSchema)).toEqual({
+84 -7
View File
@@ -24,6 +24,9 @@ export interface NumberSpec extends FieldSpecBase {
min?: number;
max?: number;
step?: number;
/** Потолок из размеров исходника (ось); статический max — фолбэк. */
maxFromSource?: "width" | "height";
maxMinus?: number;
}
export interface SliderSpec extends FieldSpecBase {
@@ -73,10 +76,9 @@ export interface DimensionSpec extends FieldSpecBase {
width: number;
height: number;
defaultFromSource?: boolean;
/**
* Id checkbox-поля «сохранять пропорции»: пока оно включено, правка одной
* оси пересчитывает вторую по аспекту текущего исходника.
*/
/** Потолок каждой оси из соответствующего размера исходника. */
maxFromSource?: boolean;
/** Id checkbox-поля «сохранять пропорции». */
lockAspectWith?: string;
/** Показывать чипы быстрого выбора из SIZE_PRESETS. */
presets?: boolean;
@@ -286,6 +288,7 @@ export const field = {
width: number;
height: number;
defaultFromSource?: boolean;
maxFromSource?: boolean;
lockAspectWith?: string;
presets?: boolean;
}): Field<Dimension> => ({
@@ -394,6 +397,68 @@ function clamp(value: number, min?: number, max?: number): number {
return value;
}
/** Потолок поля: из размеров исходника при maxFromSource, иначе статический. */
export function effectiveMax(
spec: NumberSpec,
source?: Dimension,
): number | undefined {
if (spec.maxFromSource && source) {
return source[spec.maxFromSource] - (spec.maxMinus ?? 0);
}
return spec.max;
}
function dimensionAxisMax(
spec: DimensionSpec,
axis: "width" | "height",
source?: Dimension,
): number {
return spec.maxFromSource && source ? source[axis] : spec.max;
}
/** Поджимает maxFromSource-поля к актуальному потолку (сменился исходник). */
export function clampSourceAwareMaxes<P>(
schema: ToolSchema<P>,
params: Record<string, unknown>,
source?: Dimension,
): Record<string, unknown> {
if (!source) return params;
let changed = false;
const out = { ...params };
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "number" && spec.maxFromSource) {
const v = out[key as string];
if (typeof v !== "number" || !Number.isFinite(v)) continue;
const clamped = clamp(v, spec.min, effectiveMax(spec, source));
if (clamped !== v) {
out[key as string] = clamped;
changed = true;
}
} else if (spec.kind === "dimension" && spec.maxFromSource) {
const v = out[key as string] as Partial<Dimension> | undefined;
if (typeof v?.width !== "number" || typeof v?.height !== "number") {
continue;
}
const width = clamp(
v.width,
spec.min,
dimensionAxisMax(spec, "width", source),
);
const height = clamp(
v.height,
spec.min,
dimensionAxisMax(spec, "height", source),
);
if (width !== v.width || height !== v.height) {
out[key as string] = { width, height };
changed = true;
}
}
}
return changed ? out : params;
}
/**
* Пересчёт при включённом lockAspect: ведущая ось (последняя правка
* пользователя) сохраняется, вторая подгоняется под аспект исходника.
@@ -500,7 +565,11 @@ export function sanitizeSchemaParams<P>(
case "slider": {
const n =
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
out[key] = clamp(n, spec.min, spec.max);
const max =
spec.kind === "number"
? effectiveMax(spec, context?.source)
: spec.max;
out[key] = clamp(n, spec.min, max);
break;
}
case "select":
@@ -543,8 +612,16 @@ export function sanitizeSchemaParams<P>(
? defaults
: { width: w, height: h };
out[key] = {
width: clamp(sourceSize.width, spec.min, spec.max),
height: clamp(sourceSize.height, spec.min, spec.max),
width: clamp(
sourceSize.width,
spec.min,
dimensionAxisMax(spec, "width", context?.source),
),
height: clamp(
sourceSize.height,
spec.min,
dimensionAxisMax(spec, "height", context?.source),
),
};
break;
}
+1
View File
@@ -31,6 +31,7 @@ export type {
Page,
PageStep,
ResultKind,
ResultNote,
Tool,
ToolContext,
ToolImageFile,
+57 -10
View File
@@ -199,14 +199,14 @@ describe("registry-new (переведённые инструменты)", () =>
const tool = TOOLS.find((t) => t.id === "create-empty")!;
const d = defaultSchemaParams(tool.schema);
expect(d.size).toEqual({ width: 800, height: 600 });
expect(d.transparent).toBe(true);
expect(d.opacity).toBe(0);
});
it("sanitize клампит dimension к min/max и чинит мусор", () => {
const tool = TOOLS.find((t) => t.id === "create-empty")!;
const s = sanitizeSchemaParams(tool.schema, {
size: { width: 999999, height: -5 },
transparent: false,
opacity: 50,
color: "#ff0000",
});
expect(s.size).toEqual({ width: 20000, height: 1 });
@@ -218,7 +218,7 @@ describe("registry-new (переведённые инструменты)", () =>
const tool = TOOLS.find((t) => t.id === "create-empty")!;
const params = sanitizeSchemaParams(tool.schema, {
size: { width: 320, height: 200 },
transparent: true,
opacity: 0,
color: "#ff0000",
});
const img = asImage(await tool.run({ params }));
@@ -262,7 +262,7 @@ describe("registry-new (переведённые инструменты)", () =>
rows: 3,
lineWidth: 2,
color: "#000000",
transparentBg: true,
bgOpacity: 0,
},
],
])("генератор %s даёт картинку по dimension", async (id, params) => {
@@ -336,7 +336,7 @@ describe("registry-new (переведённые инструменты)", () =>
source: img,
params: sanitizeSchemaParams(tool.schema, {
size: { width: 60, height: 30 },
transparent: true,
opacity: 0,
color: "#ffffff",
}),
}),
@@ -346,6 +346,53 @@ describe("registry-new (переведённые инструменты)", () =>
expect(out.data[3]).toBe(0);
});
it("crop: resultNote предупреждает, когда результат ужался по границам", () => {
const tool = TOOLS.find((t) => t.id === "crop")!;
expect(
tool.resultNote?.(
{ x: 700, y: 0, size: { width: 256, height: 256 } },
solid(132, 256),
),
).toEqual({ key: "resultCard.clamped" });
expect(
tool.resultNote?.(
{ x: 0, y: 0, size: { width: 256, height: 256 } },
solid(256, 256),
),
).toBeNull();
});
it("resize: resultNote при keepAspect и скорректированной стороне", () => {
const tool = TOOLS.find((t) => t.id === "resize")!;
const p = { size: { width: 500, height: 300 }, keepAspect: true };
expect(tool.resultNote?.(p, solid(300, 300))).toEqual({
key: "resultCard.aspectAdjusted",
tone: "info",
});
expect(tool.resultNote?.(p, solid(500, 300))).toBeNull();
// без keepAspect результат всегда как запрошено
expect(
tool.resultNote?.(
{ size: { width: 500, height: 300 }, keepAspect: false },
solid(500, 300),
),
).toBeNull();
});
it("fit-on-background: resultNote, когда картинка крупнее канваса", () => {
const tool = TOOLS.find((t) => t.id === "fit-on-background")!;
const p = {
size: { width: 100, height: 100 },
opacity: 100,
color: "#ffffff",
};
expect(tool.resultNote?.(p, solid(200, 100))).toEqual({
key: "resultCard.grown",
tone: "info",
});
expect(tool.resultNote?.(p, solid(100, 100))).toBeNull();
});
it("change-canvas-size использует anchor для позиции", async () => {
const tool = TOOLS.find((t) => t.id === "change-canvas-size")!;
const img = solid(10, 10);
@@ -633,7 +680,7 @@ describe("registry-new (переведённые инструменты)", () =>
bold: true,
color: "#111318",
});
expect(d.transparentBg).toBe(false);
expect(d.bgOpacity).toBe(100);
const s = sanitizeSchemaParams(tool.schema, {
style: { font: "sans", size: 9999, bold: false, color: "#123abc" },
padding: 200,
@@ -699,18 +746,18 @@ describe("split-into-parts", () => {
}
});
it("работает максимум 6x6 = 36 частей", async () => {
it("принимает максимальную сетку 32x32 = 1024 части (потолок стража)", async () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
const files = asFiles(
await tool.run({
source: solid(120, 120),
params: sanitizeSchemaParams(tool.schema, {
columns: 6,
rows: 6,
columns: 32,
rows: 32,
}),
}),
).files;
expect(files).toHaveLength(36);
expect(files).toHaveLength(1024);
});
it("выбрасывает errors.tooManyParts при переполнении", () => {
+31 -28
View File
@@ -78,7 +78,7 @@ function angleGradient(
interface CreateEmptyParams {
size: Dimension;
transparent: boolean;
opacity: number;
color: string;
}
@@ -92,14 +92,20 @@ export const createEmptySchema = toolSchema<CreateEmptyParams>(
height: 600,
presets: true,
}),
transparent: field.checkbox({ label: "fields.transparent", default: true }),
opacity: field.slider({
label: "fields.opacity",
min: 0,
max: 100,
step: 1,
default: 0,
}),
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
},
{
layout: {
groups: [
{ title: "groups.canvas", fields: ["size"] },
{ title: "groups.fill", fields: ["transparent", "color"] },
{ title: "groups.fill", fields: ["opacity", "color"] },
],
},
},
@@ -112,10 +118,9 @@ const createEmpty: Tool<CreateEmptyParams> = {
run: genTool((p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
if (p.transparent) {
return solidImage(w, h, [0, 0, 0, 0]);
}
return solidImage(w, h, rgba(p.color));
const [r, g, b] = rgba(p.color).slice(0, 3);
const alpha = Math.round((Math.trunc(p.opacity) / 100) * 255);
return solidImage(w, h, [r, g, b, alpha]);
}),
};
@@ -352,7 +357,7 @@ interface DrawGridParams {
rows: number;
lineWidth: number;
color: string;
transparentBg: boolean;
bgOpacity: number;
}
export const drawGridSchema = toolSchema<DrawGridParams>(
@@ -387,9 +392,12 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
default: 2,
}),
color: field.color({ label: "fields.lineColor", default: "#111318" }),
transparentBg: field.checkbox({
label: "fields.transparentBg",
default: true,
bgOpacity: field.slider({
label: "fields.opacity",
min: 0,
max: 100,
step: 1,
default: 0,
}),
},
{
@@ -399,7 +407,7 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
{
title: "groups.grid",
cols: 2,
fields: ["cols", "rows", "lineWidth", "color", "transparentBg"],
fields: ["cols", "rows", "lineWidth", "color", "bgOpacity"],
},
],
},
@@ -413,15 +421,7 @@ const drawGridTool: Tool<DrawGridParams> = {
run: genTool((p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return drawGrid(
w,
h,
p.cols,
p.rows,
p.lineWidth,
p.color,
p.transparentBg,
);
return drawGrid(w, h, p.cols, p.rows, p.lineWidth, p.color, p.bgOpacity);
}),
};
@@ -481,7 +481,7 @@ const placeholder: Tool<PlaceholderParams> = {
bold: true,
color: p.color,
backgroundColor: p.backgroundColor,
transparentBg: false,
bgOpacity: 100,
padding: 0,
});
out = changeCanvasSize(label, w, h, "center");
@@ -931,7 +931,7 @@ const sortColorsTool: Tool<SortColorsParams> = {
interface TextToPngParams {
text: string;
style: FontStyle;
transparentBg: boolean;
bgOpacity: number;
backgroundColor: string;
padding: number;
}
@@ -952,9 +952,12 @@ export const textToPngSchema = toolSchema<TextToPngParams>(
bold: true,
color: "#111318",
}),
transparentBg: field.checkbox({
label: "fields.transparentBg",
default: false,
bgOpacity: field.slider({
label: "fields.opacity",
min: 0,
max: 100,
step: 1,
default: 100,
}),
backgroundColor: field.color({
label: "fields.backgroundColor",
@@ -974,7 +977,7 @@ export const textToPngSchema = toolSchema<TextToPngParams>(
{ title: "groups.text", fields: ["text", "style"] },
{
title: "groups.background",
fields: ["transparentBg", "backgroundColor"],
fields: ["bgOpacity", "backgroundColor"],
},
{ title: "groups.padding", fields: ["padding"] },
],
@@ -995,7 +998,7 @@ const textToPng: Tool<TextToPngParams> = {
bold: p.style.bold,
color: p.style.color,
backgroundColor: p.backgroundColor,
transparentBg: p.transparentBg,
bgOpacity: p.bgOpacity,
padding: p.padding,
}),
),
+52 -17
View File
@@ -65,7 +65,7 @@ const addBorder: Tool<AddBorderParams> = {
interface FitOnBackgroundParams {
size: Dimension;
transparent: boolean;
opacity: number;
color: string;
}
@@ -79,9 +79,12 @@ export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
height: 600,
presets: true,
}),
transparent: field.checkbox({
label: "fields.transparent",
default: false,
opacity: field.slider({
label: "fields.opacity",
min: 0,
max: 100,
step: 1,
default: 100,
}),
color: field.color({ label: "fields.background", default: "#ffffff" }),
},
@@ -89,7 +92,7 @@ export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
layout: {
groups: [
{ title: "groups.canvas", fields: ["size"] },
{ title: "groups.background", fields: ["transparent", "color"] },
{ title: "groups.background", fields: ["opacity", "color"] },
],
},
},
@@ -113,9 +116,15 @@ const fitOnBackground: Tool<FitOnBackgroundParams> = {
top,
Math.max(0, width - img.width - left),
Math.max(0, height - img.height - top),
p.transparent ? undefined : p.color,
p.color,
Math.round((Math.trunc(p.opacity) / 100) * 255),
);
}),
resultNote: (p, result) =>
result.width !== Math.trunc(p.size.width) ||
result.height !== Math.trunc(p.size.height)
? { key: "resultCard.grown", tone: "info" }
: null,
};
interface ChangeCanvasSizeParams {
@@ -226,6 +235,12 @@ const resizeTool: Tool<ResizeParams> = {
}
return resize(img, w, h);
}),
resultNote: (p, result) =>
p.keepAspect &&
(result.width !== Math.trunc(p.size.width) ||
result.height !== Math.trunc(p.size.height))
? { key: "resultCard.aspectAdjusted", tone: "info" }
: null,
};
interface CropParams {
@@ -239,24 +254,29 @@ export const cropSchema = toolSchema<CropParams>(
x: field.number({
label: "fields.x",
min: 0,
max: 2000,
max: 20000,
step: 1,
default: 0,
maxFromSource: "width",
maxMinus: 1,
}),
y: field.number({
label: "fields.y",
min: 0,
max: 1200,
max: 20000,
step: 1,
default: 0,
maxFromSource: "height",
maxMinus: 1,
}),
size: field.dimension({
label: "fields.cropAreaSize",
min: 1,
max: 2000,
max: 20000,
width: 1,
height: 1,
defaultFromSource: true,
maxFromSource: true,
presets: true,
}),
},
@@ -282,6 +302,11 @@ const cropTool: Tool<CropParams> = {
}
return crop(img, Math.trunc(p.x), Math.trunc(p.y), w, h);
}),
resultNote: (p, result) =>
result.width < Math.trunc(p.size.width) ||
result.height < Math.trunc(p.size.height)
? { key: "resultCard.clamped" }
: null,
};
interface RotateParams {
@@ -331,7 +356,7 @@ const flipTool: Tool<FlipParams> = {
interface AddPaddingParams {
padding: number;
transparent: boolean;
opacity: number;
color: string;
}
@@ -344,14 +369,20 @@ export const addPaddingSchema = toolSchema<AddPaddingParams>(
step: 1,
default: 10,
}),
transparent: field.checkbox({ label: "fields.transparent", default: true }),
opacity: field.slider({
label: "fields.opacity",
min: 0,
max: 100,
step: 1,
default: 0,
}),
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
},
{
layout: {
groups: [
{ title: "groups.padding", fields: ["padding"] },
{ title: "groups.fill", fields: ["transparent", "color"] },
{ title: "groups.fill", fields: ["opacity", "color"] },
],
},
},
@@ -368,7 +399,8 @@ const addPaddingTool: Tool<AddPaddingParams> = {
p.padding,
p.padding,
p.padding,
p.transparent ? undefined : p.color,
p.color,
Math.round((Math.trunc(p.opacity) / 100) * 255),
),
),
};
@@ -402,7 +434,7 @@ const tileTool: Tool<TileParams> = {
run: imgTool((img, p) => tile(img, p.columns, p.rows)),
};
const MAX_SPLIT_PARTS = 1000;
const MAX_SPLIT_PARTS = 1024;
interface SplitPartsParams {
columns: number;
@@ -413,14 +445,14 @@ export const splitPartsSchema = toolSchema<SplitPartsParams>({
columns: field.number({
label: "fields.columns",
min: 1,
max: 6,
max: 32,
step: 1,
default: 2,
}),
rows: field.number({
label: "fields.rows",
min: 1,
max: 6,
max: 32,
step: 1,
default: 2,
}),
@@ -438,7 +470,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
const rowsCount = Math.max(1, Math.trunc(params.rows));
const count = cols * rowsCount;
if (count > MAX_SPLIT_PARTS) {
throw new ToolError("errors.tooManyParts", { count });
throw new ToolError("errors.tooManyParts", {
count,
max: MAX_SPLIT_PARTS,
});
}
const parts = splitToParts(img, cols, rowsCount);
const rowLen = String(rowsCount).length;
+9
View File
@@ -62,6 +62,9 @@ export interface VerdictResult {
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
/** Заметка у тайла результата: i18n-ключ и тон бейджа (warning по умолчанию). */
export type ResultNote = { key: string; tone?: "warning" | "info" };
/**
* Инструмент: уникальная реализация без адреса, текстов и категории — всё это
* у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
@@ -86,6 +89,12 @@ export type Tool<P = Record<string, unknown>> = {
domOnly?: boolean;
/** Формат/качество скачивания результата; по умолчанию — PNG. */
output?: OutputFormat;
/**
* Заметка у тайла результата, когда итог отличается от запрошенного:
* ужат по границам (crop), скорректирован по пропорциям (resize),
* вырос за канвас (fit-on-background).
*/
resultNote?: (params: P, result: PixelImage) => ResultNote | null;
};
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */