Compare commits
9
Commits
f4216e6868
...
7aa50eee6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa50eee6c | ||
|
|
ada96b3534 | ||
|
|
10278981ba | ||
|
|
5093b50f5c | ||
|
|
65f12c931b | ||
|
|
40eee984da | ||
|
|
d373cc0daf | ||
|
|
a16a7b92c7 | ||
|
|
f990ad6767 |
@@ -1 +1,10 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# Reference source (legacy Next.js design in refs/): branding icons are not needed
|
||||||
|
refs/public/icon.svg
|
||||||
|
refs/public/icon-dark-32x32.png
|
||||||
|
refs/public/icon-light-32x32.png
|
||||||
|
refs/public/apple-icon.png
|
||||||
|
refs/public/placeholder-logo.svg
|
||||||
|
refs/public/placeholder-logo.png
|
||||||
|
refs/public/placeholder.svg
|
||||||
|
|||||||
+13
-3
@@ -28,10 +28,10 @@
|
|||||||
- **F1. Фон холста.** Реф: плоский серый `#EEF1F4`. Наш: белый + blueprint-сетка.
|
- **F1. Фон холста.** Реф: плоский серый `#EEF1F4`. Наш: белый + blueprint-сетка.
|
||||||
Решение: совпасть с рефом (убрать сетку на демо) или оставить сетку как сигнатуру нового дизайна.
|
Решение: совпасть с рефом (убрать сетку на демо) или оставить сетку как сигнатуру нового дизайна.
|
||||||
- **F2. Бейдж типа шага** (BACKGROUND/TRANSFORM/STYLE). Реф: приглушённый серый `#6D787F`.
|
- **F2. Бейдж типа шага** (BACKGROUND/TRANSFORM/STYLE). Реф: приглушённый серый `#6D787F`.
|
||||||
Наш (`StepCard` type → `Badge accent`): синий `#1769D2`. Решение: сделать приглушённым
|
Решено C16: `StepCard` рендерит `type` как приглушённый моно-текст (не синий бейдж);
|
||||||
(меняет `StepCard` глобально) или оставить синим.
|
индекс шага (`step-index`) — синий `#1769D2`, как в рефе.
|
||||||
- **F3. Подписи превью-тайлов** (SOURCE / STEP 01 / FINAL OUTPUT). Реф: синий `#1769D2`,
|
- **F3. Подписи превью-тайлов** (SOURCE / STEP 01 / FINAL OUTPUT). Реф: синий `#1769D2`,
|
||||||
weight 400. Наш (`PreviewTile` label): серый, weight 500.
|
weight 400. Решено C16: `PreviewTile` label — синий `#1769D2`.
|
||||||
- **F4. Раскладка preview-stack.** Реф: одна колонка (5 строк, выше). Наш: 2 колонки (grid).
|
- **F4. Раскладка preview-stack.** Реф: одна колонка (5 строк, выше). Наш: 2 колонки (grid).
|
||||||
Решение: одна колонка (как реф) или оставить 2 колонки как выбор нового дизайна.
|
Решение: одна колонка (как реф) или оставить 2 колонки как выбор нового дизайна.
|
||||||
- **F5. Вес лейблов контролов.** Реф: 400. Наш (`MonoLabel`): 500. Мелко.
|
- **F5. Вес лейблов контролов.** Реф: 400. Наш (`MonoLabel`): 500. Мелко.
|
||||||
@@ -76,6 +76,16 @@
|
|||||||
не меняются. Light-токены уже совпадают с рефом (`--background`, `--panel`,
|
не меняются. Light-токены уже совпадают с рефом (`--background`, `--panel`,
|
||||||
`--foreground`, `--muted`, `--line`, `--blue` равны).
|
`--foreground`, `--muted`, `--line`, `--blue` равны).
|
||||||
|
|
||||||
|
## Состояние (C16)
|
||||||
|
|
||||||
|
Цветовые расхождения против `refs-html/demo.html` устранены: стек шрифтов сверен с
|
||||||
|
рефом (`"IBM Plex Sans", sans-serif` / `"IBM Plex Mono", monospace`), бренд топбара —
|
||||||
|
`--foreground`, индекс шага — синий, тип шага и подпись плитки — как в рефе,
|
||||||
|
`Segmented` — 10px/32px. Аудит: токены light 10 / dark 16, элементы 47. Остаток —
|
||||||
|
sub-pixel (10px→9px лейблы, letter-spacing) и структурные (`display`/`rect` от наших
|
||||||
|
flex/grid раскладок против статичного мока); визуально незначимы, правим точечно при
|
||||||
|
желании. Старые тесты (583) — без регрессий.
|
||||||
|
|
||||||
## План действий (предлагаю)
|
## План действий (предлагаю)
|
||||||
|
|
||||||
1. Сразу поправить B1 + B2 (чёткие баги, улучшают все страницы).
|
1. Сразу поправить B1 + B2 (чёткие баги, улучшают все страницы).
|
||||||
|
|||||||
@@ -330,15 +330,14 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
`CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]`
|
`CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]`
|
||||||
подключим на C12).
|
подключим на C12).
|
||||||
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
|
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
|
||||||
- [ ] **[C12]** `/preview/tools/[id]` — раскладка gradient (settings-panel + preview-panel).
|
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены.
|
||||||
- [ ] **[C13]** `/preview/tools/[id]` — раскладка background-remover (comparison-grid).
|
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`); охватывает background-remover и прочие.
|
||||||
- [ ] **[C14]** step-cards для chain в tool-page.
|
- [x] **[C14]** `/preview/tools/[id]` — исполняемая цепочка: `StepCard`-шаги (параметры inline) + «Add step» (chainable), живой прогон пайплайна на исходнике, превью каждого шага + итог + Download.
|
||||||
|
|
||||||
### Шаг 4. Переключатель и проверка
|
### Шаг 4. Переключатель и проверка
|
||||||
|
|
||||||
- [ ] **[C15]** Кнопка «Preview v2» + `localStorage`-флаг в старой шапке (ведёт на
|
- [x] **[C15]** В старой шапке ссылка «Preview v2» → `/preview`, ставит `localStorage['easy-png-tools:preview-v2']='1'`.
|
||||||
`/preview`).
|
- [x] **[C16]** База под дизайн: рабочий переключатель тем (persist в localStorage, применяется на `<html>`; проверено — фон `#eef1f4`↔`#11171d`), `TopBar` приведён к виду `demo.html` (бренд + синий хлебный крош `/* страница */`, безрамочные иконки, язык — active = `--foreground`/`--background`), страницы `/preview/*` на всю ширину (убран `max-width`). Точечное выравнивание к рефу отложено: реф-дизайн неконсистентен.
|
||||||
- [ ] **[C16]** Смоук §8, скриншоты против `refs-html/`, прогон старых тестов
|
|
||||||
(доказательство отсутствия регресса). Фиксы по результатам — отдельными
|
(доказательство отсутствия регресса). Фиксы по результатам — отдельными
|
||||||
мелкими коммитами.
|
мелкими коммитами.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@
|
|||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"dev": "pnpm --dir web dev",
|
"dev": "pnpm --dir web dev",
|
||||||
"build": "pnpm --dir web build",
|
"build": "pnpm --dir web build",
|
||||||
"format": "pnpm --dir web format"
|
"format": "pnpm --dir web format",
|
||||||
|
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ku6epXBOCTuK",
|
"author": "Ku6epXBOCTuK",
|
||||||
|
|||||||
@@ -7,11 +7,6 @@
|
|||||||
@layer properties {
|
@layer properties {
|
||||||
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))) {
|
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))) {
|
||||||
*,:before,:after,::backdrop {
|
*,:before,:after,::backdrop {
|
||||||
--tw-rotate-x:initial;
|
|
||||||
--tw-rotate-y:initial;
|
|
||||||
--tw-rotate-z:initial;
|
|
||||||
--tw-skew-x:initial;
|
|
||||||
--tw-skew-y:initial;
|
|
||||||
--tw-border-style:solid;
|
--tw-border-style:solid;
|
||||||
--tw-font-weight:initial;
|
--tw-font-weight:initial;
|
||||||
--tw-shadow:0 0 #0000;
|
--tw-shadow:0 0 #0000;
|
||||||
@@ -290,13 +285,7 @@ button,input:where([type=button],[type=reset],[type=submit]) {
|
|||||||
}
|
}
|
||||||
@layer components;
|
@layer components;
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.static {
|
.inline-flex {
|
||||||
position:static
|
|
||||||
}
|
|
||||||
.inline {
|
|
||||||
display:inline
|
|
||||||
}
|
|
||||||
.inline-flex {
|
|
||||||
display:inline-flex
|
display:inline-flex
|
||||||
}
|
}
|
||||||
.size-6 {
|
.size-6 {
|
||||||
@@ -330,9 +319,6 @@ button,input:where([type=button],[type=reset],[type=submit]) {
|
|||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink:0
|
flex-shrink:0
|
||||||
}
|
}
|
||||||
.transform {
|
|
||||||
transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)
|
|
||||||
}
|
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items:center
|
align-items:center
|
||||||
}
|
}
|
||||||
@@ -630,7 +616,7 @@ button {
|
|||||||
--line:#33414c;
|
--line:#33414c;
|
||||||
--muted:#91a0ac;
|
--muted:#91a0ac;
|
||||||
--blue:#54a2ff;
|
--blue:#54a2ff;
|
||||||
background-image:linear-gradient(#1b252d 1px,#0000 1px),linear-gradient(90deg,#1b252d 1px,#0000 1px)
|
background-image:linear-gradient(#1e2a3447 1px,#0000 1px),linear-gradient(90deg,#1e2a3447 1px,#0000 1px)
|
||||||
}
|
}
|
||||||
.topbar,.brand,.top-actions,.language,.status,.title-row,.pipeline-head,.step-heading,.step-tools,.pipeline-footer,.preview-top,.footer {
|
.topbar,.brand,.top-actions,.language,.status,.title-row,.pipeline-head,.step-heading,.step-tools,.pipeline-footer,.preview-top,.footer {
|
||||||
align-items:center;
|
align-items:center;
|
||||||
@@ -643,6 +629,26 @@ button {
|
|||||||
height:64px;
|
height:64px;
|
||||||
padding:0 clamp(20px,4vw,64px)
|
padding:0 clamp(20px,4vw,64px)
|
||||||
}
|
}
|
||||||
|
.topbar>.brand {
|
||||||
|
color:var(--foreground);
|
||||||
|
text-decoration:none
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
align-items:center;
|
||||||
|
gap:22px;
|
||||||
|
margin-left:auto;
|
||||||
|
margin-right:28px;
|
||||||
|
display:flex
|
||||||
|
}
|
||||||
|
.top-nav a {
|
||||||
|
color:var(--muted);
|
||||||
|
font:10px var(--font-mono);
|
||||||
|
white-space:nowrap;
|
||||||
|
text-decoration:none
|
||||||
|
}
|
||||||
|
.top-nav a:hover,.top-nav a.active {
|
||||||
|
color:var(--blue)
|
||||||
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font:600 14px var(--font-mono);
|
font:600 14px var(--font-mono);
|
||||||
gap:10px
|
gap:10px
|
||||||
@@ -1103,6 +1109,9 @@ h1 {
|
|||||||
gap:32px
|
gap:32px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.dark-mode .app-shell {
|
||||||
|
background-image:linear-gradient(#1e2a3447 1px,#0000 1px),linear-gradient(90deg,#1e2a3447 1px,#0000 1px)
|
||||||
}
|
}
|
||||||
.dark-mode .tile-canvas {
|
.dark-mode .tile-canvas {
|
||||||
background-color:#202a31;
|
background-color:#202a31;
|
||||||
@@ -1113,7 +1122,22 @@ h1 {
|
|||||||
}
|
}
|
||||||
@media (max-width:800px) {
|
@media (max-width:800px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
padding:0 16px
|
flex-wrap:wrap;
|
||||||
|
gap:12px;
|
||||||
|
height:auto;
|
||||||
|
min-height:64px;
|
||||||
|
padding:12px 16px
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
order:3;
|
||||||
|
gap:12px;
|
||||||
|
width:100%;
|
||||||
|
margin:0;
|
||||||
|
padding-bottom:2px;
|
||||||
|
overflow:auto
|
||||||
|
}
|
||||||
|
.top-nav a {
|
||||||
|
font-size:9px
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
display:none
|
display:none
|
||||||
@@ -1532,9 +1556,9 @@ h1 {
|
|||||||
}
|
}
|
||||||
.catalog-page {
|
.catalog-page {
|
||||||
width:100%;
|
width:100%;
|
||||||
max-width:1680px;
|
max-width:none;
|
||||||
margin:0 auto;
|
margin:0;
|
||||||
padding:64px clamp(24px,4vw,72px) 72px
|
padding:60px clamp(24px,4vw,72px) 72px
|
||||||
}
|
}
|
||||||
.catalog-head {
|
.catalog-head {
|
||||||
border-bottom:1px solid var(--line);
|
border-bottom:1px solid var(--line);
|
||||||
@@ -1803,26 +1827,6 @@ h1 {
|
|||||||
gap:12px 18px
|
gap:12px 18px
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
@property --tw-rotate-x {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-rotate-y {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-rotate-z {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-skew-x {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-skew-y {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
}
|
||||||
@property --tw-border-style {
|
@property --tw-border-style {
|
||||||
syntax:"*";
|
syntax:"*";
|
||||||
@@ -1984,43 +1988,57 @@ h1 {
|
|||||||
<!--$-->
|
<!--$-->
|
||||||
<!--/$-->
|
<!--/$-->
|
||||||
</div>
|
</div>
|
||||||
<main class="app-shell dark-mode">
|
<main class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<a class="brand" href="/">
|
||||||
<span class="brand-mark">EP
|
<span class="brand-mark">EP
|
||||||
</span>
|
</span>
|
||||||
<span>easy-png-tools
|
<span>easy-png-tools
|
||||||
</span>
|
</span>
|
||||||
<span class="version">/ BACKGROUND REMOVER
|
<span class="version">/
|
||||||
|
<!-- -->BACKGROUND REMOVER
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</a>
|
||||||
|
<nav class="top-nav" aria-label="Primary navigation">
|
||||||
|
<a class="" href="/">Workspace
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/list-tools">Catalog
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/gradient">Gradient
|
||||||
|
</a>
|
||||||
|
<a class="active" href="/easy-png-tools/background-remover">Background remover
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<span class="status">
|
<span class="status">
|
||||||
<i>
|
<i>
|
||||||
</i> AUTO PROCESSING
|
</i>
|
||||||
|
<!-- -->AUTO PROCESSING
|
||||||
</span>
|
</span>
|
||||||
<button class="icon-btn" aria-label="Toggle theme">
|
<button class="icon-btn" aria-label="Help">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-question-mark" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="4">
|
<circle cx="12" cy="12" r="10">
|
||||||
</circle>
|
</circle>
|
||||||
<path d="M12 2v2">
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3">
|
||||||
</path>
|
</path>
|
||||||
<path d="M12 20v2">
|
<path d="M12 17h.01">
|
||||||
</path>
|
|
||||||
<path d="m4.93 4.93 1.41 1.41">
|
|
||||||
</path>
|
|
||||||
<path d="m17.66 17.66 1.41 1.41">
|
|
||||||
</path>
|
|
||||||
<path d="M2 12h2">
|
|
||||||
</path>
|
|
||||||
<path d="M20 12h2">
|
|
||||||
</path>
|
|
||||||
<path d="m6.34 17.66-1.41 1.41">
|
|
||||||
</path>
|
|
||||||
<path d="m19.07 4.93-1.41 1.41">
|
|
||||||
</path>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="icon-btn" aria-label="Toggle theme">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon" aria-hidden="true">
|
||||||
|
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="language">
|
||||||
|
<button class="active">RU
|
||||||
|
</button>
|
||||||
|
<span>/
|
||||||
|
</span>
|
||||||
|
<button>EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="tool-page">
|
<div class="tool-page">
|
||||||
@@ -2214,5 +2232,19 @@ h1 {
|
|||||||
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
|
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
|
||||||
</template>
|
</template>
|
||||||
<!--/$-->
|
<!--/$-->
|
||||||
</body>
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.querySelector('button[aria-label="Toggle theme"]');
|
||||||
|
var shell = document.querySelector('.app-shell');
|
||||||
|
if (!btn || window.__themeToggleInjected) return;
|
||||||
|
window.__themeToggleInjected = true;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.documentElement.classList.toggle('dark-mode');
|
||||||
|
if (shell) shell.classList.toggle('dark-mode');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+78
-46
@@ -7,11 +7,6 @@
|
|||||||
@layer properties {
|
@layer properties {
|
||||||
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))) {
|
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))) {
|
||||||
*,:before,:after,::backdrop {
|
*,:before,:after,::backdrop {
|
||||||
--tw-rotate-x:initial;
|
|
||||||
--tw-rotate-y:initial;
|
|
||||||
--tw-rotate-z:initial;
|
|
||||||
--tw-skew-x:initial;
|
|
||||||
--tw-skew-y:initial;
|
|
||||||
--tw-border-style:solid;
|
--tw-border-style:solid;
|
||||||
--tw-font-weight:initial;
|
--tw-font-weight:initial;
|
||||||
--tw-shadow:0 0 #0000;
|
--tw-shadow:0 0 #0000;
|
||||||
@@ -290,13 +285,7 @@ button,input:where([type=button],[type=reset],[type=submit]) {
|
|||||||
}
|
}
|
||||||
@layer components;
|
@layer components;
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.static {
|
.inline-flex {
|
||||||
position:static
|
|
||||||
}
|
|
||||||
.inline {
|
|
||||||
display:inline
|
|
||||||
}
|
|
||||||
.inline-flex {
|
|
||||||
display:inline-flex
|
display:inline-flex
|
||||||
}
|
}
|
||||||
.size-6 {
|
.size-6 {
|
||||||
@@ -330,9 +319,6 @@ button,input:where([type=button],[type=reset],[type=submit]) {
|
|||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink:0
|
flex-shrink:0
|
||||||
}
|
}
|
||||||
.transform {
|
|
||||||
transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)
|
|
||||||
}
|
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items:center
|
align-items:center
|
||||||
}
|
}
|
||||||
@@ -630,7 +616,7 @@ button {
|
|||||||
--line:#33414c;
|
--line:#33414c;
|
||||||
--muted:#91a0ac;
|
--muted:#91a0ac;
|
||||||
--blue:#54a2ff;
|
--blue:#54a2ff;
|
||||||
background-image:linear-gradient(#1b252d 1px,#0000 1px),linear-gradient(90deg,#1b252d 1px,#0000 1px)
|
background-image:linear-gradient(#1e2a3447 1px,#0000 1px),linear-gradient(90deg,#1e2a3447 1px,#0000 1px)
|
||||||
}
|
}
|
||||||
.topbar,.brand,.top-actions,.language,.status,.title-row,.pipeline-head,.step-heading,.step-tools,.pipeline-footer,.preview-top,.footer {
|
.topbar,.brand,.top-actions,.language,.status,.title-row,.pipeline-head,.step-heading,.step-tools,.pipeline-footer,.preview-top,.footer {
|
||||||
align-items:center;
|
align-items:center;
|
||||||
@@ -643,6 +629,26 @@ button {
|
|||||||
height:64px;
|
height:64px;
|
||||||
padding:0 clamp(20px,4vw,64px)
|
padding:0 clamp(20px,4vw,64px)
|
||||||
}
|
}
|
||||||
|
.topbar>.brand {
|
||||||
|
color:var(--foreground);
|
||||||
|
text-decoration:none
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
align-items:center;
|
||||||
|
gap:22px;
|
||||||
|
margin-left:auto;
|
||||||
|
margin-right:28px;
|
||||||
|
display:flex
|
||||||
|
}
|
||||||
|
.top-nav a {
|
||||||
|
color:var(--muted);
|
||||||
|
font:10px var(--font-mono);
|
||||||
|
white-space:nowrap;
|
||||||
|
text-decoration:none
|
||||||
|
}
|
||||||
|
.top-nav a:hover,.top-nav a.active {
|
||||||
|
color:var(--blue)
|
||||||
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font:600 14px var(--font-mono);
|
font:600 14px var(--font-mono);
|
||||||
gap:10px
|
gap:10px
|
||||||
@@ -1103,6 +1109,9 @@ h1 {
|
|||||||
gap:32px
|
gap:32px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.dark-mode .app-shell {
|
||||||
|
background-image:linear-gradient(#1e2a3447 1px,#0000 1px),linear-gradient(90deg,#1e2a3447 1px,#0000 1px)
|
||||||
}
|
}
|
||||||
.dark-mode .tile-canvas {
|
.dark-mode .tile-canvas {
|
||||||
background-color:#202a31;
|
background-color:#202a31;
|
||||||
@@ -1113,7 +1122,22 @@ h1 {
|
|||||||
}
|
}
|
||||||
@media (max-width:800px) {
|
@media (max-width:800px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
padding:0 16px
|
flex-wrap:wrap;
|
||||||
|
gap:12px;
|
||||||
|
height:auto;
|
||||||
|
min-height:64px;
|
||||||
|
padding:12px 16px
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
order:3;
|
||||||
|
gap:12px;
|
||||||
|
width:100%;
|
||||||
|
margin:0;
|
||||||
|
padding-bottom:2px;
|
||||||
|
overflow:auto
|
||||||
|
}
|
||||||
|
.top-nav a {
|
||||||
|
font-size:9px
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
display:none
|
display:none
|
||||||
@@ -1532,9 +1556,9 @@ h1 {
|
|||||||
}
|
}
|
||||||
.catalog-page {
|
.catalog-page {
|
||||||
width:100%;
|
width:100%;
|
||||||
max-width:1680px;
|
max-width:none;
|
||||||
margin:0 auto;
|
margin:0;
|
||||||
padding:64px clamp(24px,4vw,72px) 72px
|
padding:60px clamp(24px,4vw,72px) 72px
|
||||||
}
|
}
|
||||||
.catalog-head {
|
.catalog-head {
|
||||||
border-bottom:1px solid var(--line);
|
border-bottom:1px solid var(--line);
|
||||||
@@ -1803,26 +1827,6 @@ h1 {
|
|||||||
gap:12px 18px
|
gap:12px 18px
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
@property --tw-rotate-x {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-rotate-y {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-rotate-z {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-skew-x {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-skew-y {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
}
|
||||||
@property --tw-border-style {
|
@property --tw-border-style {
|
||||||
syntax:"*";
|
syntax:"*";
|
||||||
@@ -1986,18 +1990,30 @@ h1 {
|
|||||||
</div>
|
</div>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<a class="brand" href="/">
|
||||||
<span class="brand-mark">EP
|
<span class="brand-mark">EP
|
||||||
</span>
|
</span>
|
||||||
<span>easy-png-tools
|
<span>easy-png-tools
|
||||||
</span>
|
</span>
|
||||||
<span class="version">/ DEMO
|
<span class="version">/
|
||||||
|
<!-- -->WORKSPACE
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</a>
|
||||||
|
<nav class="top-nav" aria-label="Primary navigation">
|
||||||
|
<a class="" href="/">Workspace
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/list-tools">Catalog
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/gradient">Gradient
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/background-remover">Background remover
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<span class="status">
|
<span class="status">
|
||||||
<i>
|
<i>
|
||||||
</i> AUTO PIPELINE
|
</i>
|
||||||
|
<!-- -->AUTO PIPELINE
|
||||||
</span>
|
</span>
|
||||||
<button class="icon-btn" aria-label="Help">
|
<button class="icon-btn" aria-label="Help">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-question-mark" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-question-mark" aria-hidden="true">
|
||||||
@@ -2018,7 +2034,9 @@ h1 {
|
|||||||
<div class="language">
|
<div class="language">
|
||||||
<button class="active">RU
|
<button class="active">RU
|
||||||
</button>
|
</button>
|
||||||
<button class="">EN
|
<span>/
|
||||||
|
</span>
|
||||||
|
<button>EN
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2572,5 +2590,19 @@ h1 {
|
|||||||
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
|
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
|
||||||
</template>
|
</template>
|
||||||
<!--/$-->
|
<!--/$-->
|
||||||
</body>
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.querySelector('button[aria-label="Toggle theme"]');
|
||||||
|
var shell = document.querySelector('.app-shell');
|
||||||
|
if (!btn || window.__themeToggleInjected) return;
|
||||||
|
window.__themeToggleInjected = true;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.documentElement.classList.toggle('dark-mode');
|
||||||
|
if (shell) shell.classList.toggle('dark-mode');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+98
-62
@@ -7,11 +7,6 @@
|
|||||||
@layer properties {
|
@layer properties {
|
||||||
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))) {
|
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))) {
|
||||||
*,:before,:after,::backdrop {
|
*,:before,:after,::backdrop {
|
||||||
--tw-rotate-x:initial;
|
|
||||||
--tw-rotate-y:initial;
|
|
||||||
--tw-rotate-z:initial;
|
|
||||||
--tw-skew-x:initial;
|
|
||||||
--tw-skew-y:initial;
|
|
||||||
--tw-border-style:solid;
|
--tw-border-style:solid;
|
||||||
--tw-font-weight:initial;
|
--tw-font-weight:initial;
|
||||||
--tw-shadow:0 0 #0000;
|
--tw-shadow:0 0 #0000;
|
||||||
@@ -290,13 +285,7 @@ button,input:where([type=button],[type=reset],[type=submit]) {
|
|||||||
}
|
}
|
||||||
@layer components;
|
@layer components;
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.static {
|
.inline-flex {
|
||||||
position:static
|
|
||||||
}
|
|
||||||
.inline {
|
|
||||||
display:inline
|
|
||||||
}
|
|
||||||
.inline-flex {
|
|
||||||
display:inline-flex
|
display:inline-flex
|
||||||
}
|
}
|
||||||
.size-6 {
|
.size-6 {
|
||||||
@@ -330,9 +319,6 @@ button,input:where([type=button],[type=reset],[type=submit]) {
|
|||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink:0
|
flex-shrink:0
|
||||||
}
|
}
|
||||||
.transform {
|
|
||||||
transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)
|
|
||||||
}
|
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items:center
|
align-items:center
|
||||||
}
|
}
|
||||||
@@ -630,7 +616,7 @@ button {
|
|||||||
--line:#33414c;
|
--line:#33414c;
|
||||||
--muted:#91a0ac;
|
--muted:#91a0ac;
|
||||||
--blue:#54a2ff;
|
--blue:#54a2ff;
|
||||||
background-image:linear-gradient(#1b252d 1px,#0000 1px),linear-gradient(90deg,#1b252d 1px,#0000 1px)
|
background-image:linear-gradient(#1e2a3447 1px,#0000 1px),linear-gradient(90deg,#1e2a3447 1px,#0000 1px)
|
||||||
}
|
}
|
||||||
.topbar,.brand,.top-actions,.language,.status,.title-row,.pipeline-head,.step-heading,.step-tools,.pipeline-footer,.preview-top,.footer {
|
.topbar,.brand,.top-actions,.language,.status,.title-row,.pipeline-head,.step-heading,.step-tools,.pipeline-footer,.preview-top,.footer {
|
||||||
align-items:center;
|
align-items:center;
|
||||||
@@ -643,6 +629,26 @@ button {
|
|||||||
height:64px;
|
height:64px;
|
||||||
padding:0 clamp(20px,4vw,64px)
|
padding:0 clamp(20px,4vw,64px)
|
||||||
}
|
}
|
||||||
|
.topbar>.brand {
|
||||||
|
color:var(--foreground);
|
||||||
|
text-decoration:none
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
align-items:center;
|
||||||
|
gap:22px;
|
||||||
|
margin-left:auto;
|
||||||
|
margin-right:28px;
|
||||||
|
display:flex
|
||||||
|
}
|
||||||
|
.top-nav a {
|
||||||
|
color:var(--muted);
|
||||||
|
font:10px var(--font-mono);
|
||||||
|
white-space:nowrap;
|
||||||
|
text-decoration:none
|
||||||
|
}
|
||||||
|
.top-nav a:hover,.top-nav a.active {
|
||||||
|
color:var(--blue)
|
||||||
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font:600 14px var(--font-mono);
|
font:600 14px var(--font-mono);
|
||||||
gap:10px
|
gap:10px
|
||||||
@@ -1103,6 +1109,9 @@ h1 {
|
|||||||
gap:32px
|
gap:32px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.dark-mode .app-shell {
|
||||||
|
background-image:linear-gradient(#1e2a3447 1px,#0000 1px),linear-gradient(90deg,#1e2a3447 1px,#0000 1px)
|
||||||
}
|
}
|
||||||
.dark-mode .tile-canvas {
|
.dark-mode .tile-canvas {
|
||||||
background-color:#202a31;
|
background-color:#202a31;
|
||||||
@@ -1113,7 +1122,22 @@ h1 {
|
|||||||
}
|
}
|
||||||
@media (max-width:800px) {
|
@media (max-width:800px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
padding:0 16px
|
flex-wrap:wrap;
|
||||||
|
gap:12px;
|
||||||
|
height:auto;
|
||||||
|
min-height:64px;
|
||||||
|
padding:12px 16px
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
order:3;
|
||||||
|
gap:12px;
|
||||||
|
width:100%;
|
||||||
|
margin:0;
|
||||||
|
padding-bottom:2px;
|
||||||
|
overflow:auto
|
||||||
|
}
|
||||||
|
.top-nav a {
|
||||||
|
font-size:9px
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
display:none
|
display:none
|
||||||
@@ -1532,9 +1556,9 @@ h1 {
|
|||||||
}
|
}
|
||||||
.catalog-page {
|
.catalog-page {
|
||||||
width:100%;
|
width:100%;
|
||||||
max-width:1680px;
|
max-width:none;
|
||||||
margin:0 auto;
|
margin:0;
|
||||||
padding:64px clamp(24px,4vw,72px) 72px
|
padding:60px clamp(24px,4vw,72px) 72px
|
||||||
}
|
}
|
||||||
.catalog-head {
|
.catalog-head {
|
||||||
border-bottom:1px solid var(--line);
|
border-bottom:1px solid var(--line);
|
||||||
@@ -1803,26 +1827,6 @@ h1 {
|
|||||||
gap:12px 18px
|
gap:12px 18px
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
@property --tw-rotate-x {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-rotate-y {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-rotate-z {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-skew-x {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-skew-y {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
}
|
||||||
@property --tw-border-style {
|
@property --tw-border-style {
|
||||||
syntax:"*";
|
syntax:"*";
|
||||||
@@ -1984,39 +1988,57 @@ h1 {
|
|||||||
<!--$-->
|
<!--$-->
|
||||||
<!--/$-->
|
<!--/$-->
|
||||||
</div>
|
</div>
|
||||||
<main class="app-shell dark-mode">
|
<main class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<a class="brand" href="/">
|
||||||
<span class="brand-mark">EP
|
<span class="brand-mark">EP
|
||||||
</span>
|
</span>
|
||||||
<span>easy-png-tools
|
<span>easy-png-tools
|
||||||
</span>
|
</span>
|
||||||
<span class="version">/ GRADIENT
|
<span class="version">/
|
||||||
|
<!-- -->GRADIENT
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</a>
|
||||||
|
<nav class="top-nav" aria-label="Primary navigation">
|
||||||
|
<a class="" href="/">Workspace
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/list-tools">Catalog
|
||||||
|
</a>
|
||||||
|
<a class="active" href="/easy-png-tools/gradient">Gradient
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/background-remover">Background remover
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<button class="icon-btn" aria-label="Toggle theme">
|
<span class="status">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun" aria-hidden="true">
|
<i>
|
||||||
<circle cx="12" cy="12" r="4">
|
</i>
|
||||||
|
<!-- -->LIVE PREVIEW
|
||||||
|
</span>
|
||||||
|
<button class="icon-btn" aria-label="Help">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-question-mark" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10">
|
||||||
</circle>
|
</circle>
|
||||||
<path d="M12 2v2">
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3">
|
||||||
</path>
|
</path>
|
||||||
<path d="M12 20v2">
|
<path d="M12 17h.01">
|
||||||
</path>
|
|
||||||
<path d="m4.93 4.93 1.41 1.41">
|
|
||||||
</path>
|
|
||||||
<path d="m17.66 17.66 1.41 1.41">
|
|
||||||
</path>
|
|
||||||
<path d="M2 12h2">
|
|
||||||
</path>
|
|
||||||
<path d="M20 12h2">
|
|
||||||
</path>
|
|
||||||
<path d="m6.34 17.66-1.41 1.41">
|
|
||||||
</path>
|
|
||||||
<path d="m19.07 4.93-1.41 1.41">
|
|
||||||
</path>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="icon-btn" aria-label="Toggle theme">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon" aria-hidden="true">
|
||||||
|
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="language">
|
||||||
|
<button class="active">RU
|
||||||
|
</button>
|
||||||
|
<span>/
|
||||||
|
</span>
|
||||||
|
<button>EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="tool-page">
|
<div class="tool-page">
|
||||||
@@ -2205,5 +2227,19 @@ opacity: 1;</pre>
|
|||||||
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
|
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
|
||||||
</template>
|
</template>
|
||||||
<!--/$-->
|
<!--/$-->
|
||||||
</body>
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.querySelector('button[aria-label="Toggle theme"]');
|
||||||
|
var shell = document.querySelector('.app-shell');
|
||||||
|
if (!btn || window.__themeToggleInjected) return;
|
||||||
|
window.__themeToggleInjected = true;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.documentElement.classList.toggle('dark-mode');
|
||||||
|
if (shell) shell.classList.toggle('dark-mode');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+78
-46
@@ -7,11 +7,6 @@
|
|||||||
@layer properties {
|
@layer properties {
|
||||||
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))) {
|
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))) {
|
||||||
*,:before,:after,::backdrop {
|
*,:before,:after,::backdrop {
|
||||||
--tw-rotate-x:initial;
|
|
||||||
--tw-rotate-y:initial;
|
|
||||||
--tw-rotate-z:initial;
|
|
||||||
--tw-skew-x:initial;
|
|
||||||
--tw-skew-y:initial;
|
|
||||||
--tw-border-style:solid;
|
--tw-border-style:solid;
|
||||||
--tw-font-weight:initial;
|
--tw-font-weight:initial;
|
||||||
--tw-shadow:0 0 #0000;
|
--tw-shadow:0 0 #0000;
|
||||||
@@ -290,13 +285,7 @@ button,input:where([type=button],[type=reset],[type=submit]) {
|
|||||||
}
|
}
|
||||||
@layer components;
|
@layer components;
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.static {
|
.inline-flex {
|
||||||
position:static
|
|
||||||
}
|
|
||||||
.inline {
|
|
||||||
display:inline
|
|
||||||
}
|
|
||||||
.inline-flex {
|
|
||||||
display:inline-flex
|
display:inline-flex
|
||||||
}
|
}
|
||||||
.size-6 {
|
.size-6 {
|
||||||
@@ -330,9 +319,6 @@ button,input:where([type=button],[type=reset],[type=submit]) {
|
|||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink:0
|
flex-shrink:0
|
||||||
}
|
}
|
||||||
.transform {
|
|
||||||
transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)
|
|
||||||
}
|
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items:center
|
align-items:center
|
||||||
}
|
}
|
||||||
@@ -630,7 +616,7 @@ button {
|
|||||||
--line:#33414c;
|
--line:#33414c;
|
||||||
--muted:#91a0ac;
|
--muted:#91a0ac;
|
||||||
--blue:#54a2ff;
|
--blue:#54a2ff;
|
||||||
background-image:linear-gradient(#1b252d 1px,#0000 1px),linear-gradient(90deg,#1b252d 1px,#0000 1px)
|
background-image:linear-gradient(#1e2a3447 1px,#0000 1px),linear-gradient(90deg,#1e2a3447 1px,#0000 1px)
|
||||||
}
|
}
|
||||||
.topbar,.brand,.top-actions,.language,.status,.title-row,.pipeline-head,.step-heading,.step-tools,.pipeline-footer,.preview-top,.footer {
|
.topbar,.brand,.top-actions,.language,.status,.title-row,.pipeline-head,.step-heading,.step-tools,.pipeline-footer,.preview-top,.footer {
|
||||||
align-items:center;
|
align-items:center;
|
||||||
@@ -643,6 +629,26 @@ button {
|
|||||||
height:64px;
|
height:64px;
|
||||||
padding:0 clamp(20px,4vw,64px)
|
padding:0 clamp(20px,4vw,64px)
|
||||||
}
|
}
|
||||||
|
.topbar>.brand {
|
||||||
|
color:var(--foreground);
|
||||||
|
text-decoration:none
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
align-items:center;
|
||||||
|
gap:22px;
|
||||||
|
margin-left:auto;
|
||||||
|
margin-right:28px;
|
||||||
|
display:flex
|
||||||
|
}
|
||||||
|
.top-nav a {
|
||||||
|
color:var(--muted);
|
||||||
|
font:10px var(--font-mono);
|
||||||
|
white-space:nowrap;
|
||||||
|
text-decoration:none
|
||||||
|
}
|
||||||
|
.top-nav a:hover,.top-nav a.active {
|
||||||
|
color:var(--blue)
|
||||||
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font:600 14px var(--font-mono);
|
font:600 14px var(--font-mono);
|
||||||
gap:10px
|
gap:10px
|
||||||
@@ -1103,6 +1109,9 @@ h1 {
|
|||||||
gap:32px
|
gap:32px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.dark-mode .app-shell {
|
||||||
|
background-image:linear-gradient(#1e2a3447 1px,#0000 1px),linear-gradient(90deg,#1e2a3447 1px,#0000 1px)
|
||||||
}
|
}
|
||||||
.dark-mode .tile-canvas {
|
.dark-mode .tile-canvas {
|
||||||
background-color:#202a31;
|
background-color:#202a31;
|
||||||
@@ -1113,7 +1122,22 @@ h1 {
|
|||||||
}
|
}
|
||||||
@media (max-width:800px) {
|
@media (max-width:800px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
padding:0 16px
|
flex-wrap:wrap;
|
||||||
|
gap:12px;
|
||||||
|
height:auto;
|
||||||
|
min-height:64px;
|
||||||
|
padding:12px 16px
|
||||||
|
}
|
||||||
|
.top-nav {
|
||||||
|
order:3;
|
||||||
|
gap:12px;
|
||||||
|
width:100%;
|
||||||
|
margin:0;
|
||||||
|
padding-bottom:2px;
|
||||||
|
overflow:auto
|
||||||
|
}
|
||||||
|
.top-nav a {
|
||||||
|
font-size:9px
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
display:none
|
display:none
|
||||||
@@ -1532,9 +1556,9 @@ h1 {
|
|||||||
}
|
}
|
||||||
.catalog-page {
|
.catalog-page {
|
||||||
width:100%;
|
width:100%;
|
||||||
max-width:1680px;
|
max-width:none;
|
||||||
margin:0 auto;
|
margin:0;
|
||||||
padding:64px clamp(24px,4vw,72px) 72px
|
padding:60px clamp(24px,4vw,72px) 72px
|
||||||
}
|
}
|
||||||
.catalog-head {
|
.catalog-head {
|
||||||
border-bottom:1px solid var(--line);
|
border-bottom:1px solid var(--line);
|
||||||
@@ -1803,26 +1827,6 @@ h1 {
|
|||||||
gap:12px 18px
|
gap:12px 18px
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
@property --tw-rotate-x {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-rotate-y {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-rotate-z {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-skew-x {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
|
||||||
@property --tw-skew-y {
|
|
||||||
syntax:"*";
|
|
||||||
inherits:false
|
|
||||||
}
|
}
|
||||||
@property --tw-border-style {
|
@property --tw-border-style {
|
||||||
syntax:"*";
|
syntax:"*";
|
||||||
@@ -1986,18 +1990,30 @@ h1 {
|
|||||||
</div>
|
</div>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<a class="brand" href="/">
|
||||||
<span class="brand-mark">EP
|
<span class="brand-mark">EP
|
||||||
</span>
|
</span>
|
||||||
<span>easy-png-tools
|
<span>easy-png-tools
|
||||||
</span>
|
</span>
|
||||||
<span class="version">/ DEMO
|
<span class="version">/
|
||||||
|
<!-- -->WORKSPACE
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</a>
|
||||||
|
<nav class="top-nav" aria-label="Primary navigation">
|
||||||
|
<a class="active" href="/">Workspace
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/list-tools">Catalog
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/gradient">Gradient
|
||||||
|
</a>
|
||||||
|
<a class="" href="/easy-png-tools/background-remover">Background remover
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<span class="status">
|
<span class="status">
|
||||||
<i>
|
<i>
|
||||||
</i> AUTO PIPELINE
|
</i>
|
||||||
|
<!-- -->AUTO PIPELINE
|
||||||
</span>
|
</span>
|
||||||
<button class="icon-btn" aria-label="Help">
|
<button class="icon-btn" aria-label="Help">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-question-mark" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-question-mark" aria-hidden="true">
|
||||||
@@ -2018,7 +2034,9 @@ h1 {
|
|||||||
<div class="language">
|
<div class="language">
|
||||||
<button class="active">RU
|
<button class="active">RU
|
||||||
</button>
|
</button>
|
||||||
<button class="">EN
|
<span>/
|
||||||
|
</span>
|
||||||
|
<button>EN
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2572,5 +2590,19 @@ h1 {
|
|||||||
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
|
<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING">
|
||||||
</template>
|
</template>
|
||||||
<!--/$-->
|
<!--/$-->
|
||||||
</body>
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.querySelector('button[aria-label="Toggle theme"]');
|
||||||
|
var shell = document.querySelector('.app-shell');
|
||||||
|
if (!btn || window.__themeToggleInjected) return;
|
||||||
|
window.__themeToggleInjected = true;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.documentElement.classList.toggle('dark-mode');
|
||||||
|
if (shell) shell.classList.toggle('dark-mode');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+1010
-950
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import TopBar from '@/components/top-bar'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Download, Moon, RotateCcw, Sun, Upload, Check } from 'lucide-react'
|
import { Download, RotateCcw, Upload, Check } from 'lucide-react'
|
||||||
|
|
||||||
export default function BackgroundRemoverPage() {
|
export default function BackgroundRemoverPage() {
|
||||||
const [color, setColor] = useState('#E8EEF2')
|
const [color, setColor] = useState('#E8EEF2')
|
||||||
const [similarity, setSimilarity] = useState(72)
|
const [similarity, setSimilarity] = useState(72)
|
||||||
const [outerOnly, setOuterOnly] = useState(true)
|
const [outerOnly, setOuterOnly] = useState(true)
|
||||||
const [showMask, setShowMask] = useState(false)
|
const [showMask, setShowMask] = useState(false)
|
||||||
const [dark, setDark] = useState(true)
|
|
||||||
const [downloaded, setDownloaded] = useState(false)
|
const [downloaded, setDownloaded] = useState(false)
|
||||||
const previewBackground = showMask ? 'repeating-conic-gradient(#7b8791 0 25%, #cbd3da 0 50%) 50% / 28px 28px' : 'repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px'
|
const previewBackground = showMask ? 'repeating-conic-gradient(#7b8791 0 25%, #cbd3da 0 50%) 50% / 28px 28px' : 'repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px'
|
||||||
const subjectStyle = useMemo(() => ({ background: showMask ? '#596773' : 'linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)', opacity: showMask ? .88 : 1, clipPath: `polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)` }), [showMask])
|
const subjectStyle = useMemo(() => ({ background: showMask ? '#596773' : 'linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)', opacity: showMask ? .88 : 1, clipPath: `polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)` }), [showMask])
|
||||||
const reset = () => { setColor('#E8EEF2'); setSimilarity(72); setOuterOnly(true); setShowMask(false) }
|
const reset = () => { setColor('#E8EEF2'); setSimilarity(72); setOuterOnly(true); setShowMask(false) }
|
||||||
return <main className={`app-shell ${dark ? 'dark-mode' : ''}`}>
|
return <main className="app-shell">
|
||||||
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ BACKGROUND REMOVER</span></div><div className="top-actions"><span className="status"><i /> AUTO PROCESSING</span><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button></div></header>
|
<TopBar section="BACKGROUND REMOVER" status="AUTO PROCESSING" />
|
||||||
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Remove background.</h1><p className="lede">Select a background color and tune the edge detection. Changes are processed automatically in your browser.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Remove background.</h1><p className="lede">Select a background color and tune the edge detection. Changes are processed automatically in your browser.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
||||||
<div className="remover-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">REMOVER SETTINGS</span><strong>Configure detection</strong></div><span className="step-type">TOOL 02</span></div>
|
<div className="remover-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">REMOVER SETTINGS</span><strong>Configure detection</strong></div><span className="step-type">TOOL 02</span></div>
|
||||||
<div className="setting-group"><label>BACKGROUND COLOR</label><div className="color-field"><span className="swatch" style={{ background: color }} /><input value={color} onChange={e => setColor(e.target.value)} /><input className="native-color" type="color" value={color} onChange={e => setColor(e.target.value)} aria-label="Choose background color" /></div><div className="color-reference"><span style={{ background: color }} /> sampled from image background</div></div>
|
<div className="setting-group"><label>BACKGROUND COLOR</label><div className="color-field"><span className="swatch" style={{ background: color }} /><input value={color} onChange={e => setColor(e.target.value)} /><input className="native-color" type="color" value={color} onChange={e => setColor(e.target.value)} aria-label="Choose background color" /></div><div className="color-reference"><span style={{ background: color }} /> sampled from image background</div></div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import TopBar from '@/components/top-bar'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Check, ChevronDown, Copy, Download, Moon, RotateCcw, Sun } from 'lucide-react'
|
import { Check, ChevronDown, Copy, Download, RotateCcw } from 'lucide-react'
|
||||||
|
|
||||||
const directions = ['0° →', '45° ↗', '90° ↑', '135° ↖', '180° ←', '225° ↙', '270° ↓', '315° ↘']
|
const directions = ['0° →', '45° ↗', '90° ↑', '135° ↖', '180° ←', '225° ↙', '270° ↓', '315° ↘']
|
||||||
|
|
||||||
@@ -11,14 +12,13 @@ export default function GradientPage() {
|
|||||||
const [direction, setDirection] = useState(135)
|
const [direction, setDirection] = useState(135)
|
||||||
const [opacity, setOpacity] = useState(100)
|
const [opacity, setOpacity] = useState(100)
|
||||||
const [type, setType] = useState<'linear' | 'radial'>('linear')
|
const [type, setType] = useState<'linear' | 'radial'>('linear')
|
||||||
const [dark, setDark] = useState(true)
|
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const gradient = useMemo(() => type === 'linear' ? `linear-gradient(${direction}deg, ${start} 0%, ${end} 100%)` : `radial-gradient(circle, ${start} 0%, ${end} 100%)`, [direction, end, start, type])
|
const gradient = useMemo(() => type === 'linear' ? `linear-gradient(${direction}deg, ${start} 0%, ${end} 100%)` : `radial-gradient(circle, ${start} 0%, ${end} 100%)`, [direction, end, start, type])
|
||||||
const css = `background: ${gradient};\nopacity: ${opacity / 100};`
|
const css = `background: ${gradient};\nopacity: ${opacity / 100};`
|
||||||
const reset = () => { setStart('#1769D2'); setEnd('#00A8C7'); setDirection(135); setOpacity(100); setType('linear') }
|
const reset = () => { setStart('#1769D2'); setEnd('#00A8C7'); setDirection(135); setOpacity(100); setType('linear') }
|
||||||
const copyCss = async () => { await navigator.clipboard?.writeText(css); setCopied(true); setTimeout(() => setCopied(false), 1400) }
|
const copyCss = async () => { await navigator.clipboard?.writeText(css); setCopied(true); setTimeout(() => setCopied(false), 1400) }
|
||||||
return <main className={`app-shell ${dark ? 'dark-mode' : ''}`}>
|
return <main className="app-shell">
|
||||||
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ GRADIENT</span></div><div className="top-actions"><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button></div></header>
|
<TopBar section="GRADIENT" status="LIVE PREVIEW" />
|
||||||
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Gradient background.</h1><p className="lede">Create a clean, export-ready gradient with precise control over color, direction and transparency.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Gradient background.</h1><p className="lede">Create a clean, export-ready gradient with precise control over color, direction and transparency.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
||||||
<div className="gradient-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">GRADIENT SETTINGS</span><strong>Configure output</strong></div><span className="step-type">TOOL 01</span></div>
|
<div className="gradient-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">GRADIENT SETTINGS</span><strong>Configure output</strong></div><span className="step-type">TOOL 01</span></div>
|
||||||
<div className="setting-group"><label>GRADIENT TYPE</label><div className="segmented wide"><button className={type === 'linear' ? 'selected' : ''} onClick={() => setType('linear')}>Linear</button><button className={type === 'radial' ? 'selected' : ''} onClick={() => setType('radial')}>Radial</button></div></div>
|
<div className="setting-group"><label>GRADIENT TYPE</label><div className="segmented wide"><button className={type === 'linear' ? 'selected' : ''} onClick={() => setType('linear')}>Linear</button><button className={type === 'radial' ? 'selected' : ''} onClick={() => setType('radial')}>Radial</button></div></div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import TopBar from '@/components/top-bar'
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { ArrowUpRight, FileImage, Filter, Search, Sparkles } from "lucide-react"
|
import { ArrowUpRight, FileImage, Filter, Search, Sparkles } from "lucide-react"
|
||||||
@@ -17,11 +18,11 @@ export default function ListToolsPage() {
|
|||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [active, setActive] = useState("ALL")
|
const [active, setActive] = useState("ALL")
|
||||||
const filtered = useMemo(() => groups.map(group => ({ ...group, tools: group.tools.filter(([name, description]) => (active === "ALL" || active === group.name) && `${name} ${description}`.toLowerCase().includes(query.toLowerCase())) })).filter(group => group.tools.length), [query, active])
|
const filtered = useMemo(() => groups.map(group => ({ ...group, tools: group.tools.filter(([name, description]) => (active === "ALL" || active === group.name) && `${name} ${description}`.toLowerCase().includes(query.toLowerCase())) })).filter(group => group.tools.length), [query, active])
|
||||||
return <main className="catalog-page"><header className="catalog-nav"><Link href="/" className="catalog-brand">easy-png-tools</Link><nav><Link href="/">Workspace</Link><Link className="current" href="/easy-png-tools/list-tools">Catalog</Link></nav><span className="catalog-nav-status">LOCAL MODE / READY</span></header>
|
return <main className="app-shell"><TopBar section="CATALOG" status="LOCAL MODE / READY" /><div className="catalog-page">
|
||||||
<div className="catalog-head"><div><div className="eyebrow">EASY-PNG-TOOLS / CATALOG</div><h1>Tool catalog</h1><p>Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.</p></div><div className="catalog-total"><b>32</b><span>TOOLS<br />AVAILABLE</span></div></div>
|
<div className="catalog-head"><div><div className="eyebrow">EASY-PNG-TOOLS / CATALOG</div><h1>Tool catalog</h1><p>Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.</p></div><div className="catalog-total"><b>32</b><span>TOOLS<br />AVAILABLE</span></div></div>
|
||||||
<div className="catalog-toolbar"><label className="catalog-search"><Search size={16} /><input aria-label="Search tools" placeholder="Search tools..." value={query} onChange={e => setQuery(e.target.value)} /></label><div className="catalog-filters"><Filter size={15} />{["ALL", ...groups.map(g => g.name)].map(name => <button key={name} className={active === name ? "active" : ""} onClick={() => setActive(name)}>{name}</button>)}</div></div>
|
<div className="catalog-toolbar"><label className="catalog-search"><Search size={16} /><input aria-label="Search tools" placeholder="Search tools..." value={query} onChange={e => setQuery(e.target.value)} /></label><div className="catalog-filters"><Filter size={15} />{["ALL", ...groups.map(g => g.name)].map(name => <button key={name} className={active === name ? "active" : ""} onClick={() => setActive(name)}>{name}</button>)}</div></div>
|
||||||
<div className="catalog-groups">{filtered.map(group => <section className="catalog-group" key={group.name}><div className="group-title"><span>{group.name}</span><i>{String(group.tools.length).padStart(2, "0")} TOOLS</i></div><div className="tool-cards">{group.tools.map(([name, description, href], index) => <Link className="tool-card" href={href || "#"} key={name} onClick={e => { if (!href) e.preventDefault() }}><span className="tool-icon"><FileImage size={19} /></span><span className="tool-copy"><strong>{name}</strong><span>{description}</span></span><span className="tool-index">{String(index + 1).padStart(2, "0")}</span><ArrowUpRight size={16} className="tool-arrow" /></Link>)}</div></section>)}</div>
|
<div className="catalog-groups">{filtered.map(group => <section className="catalog-group" key={group.name}><div className="group-title"><span>{group.name}</span><i>{String(group.tools.length).padStart(2, "0")} TOOLS</i></div><div className="tool-cards">{group.tools.map(([name, description, href], index) => <Link className="tool-card" href={href || "#"} key={name} onClick={e => { if (!href) e.preventDefault() }}><span className="tool-icon"><FileImage size={19} /></span><span className="tool-copy"><strong>{name}</strong><span>{description}</span></span><span className="tool-index">{String(index + 1).padStart(2, "0")}</span><ArrowUpRight size={16} className="tool-arrow" /></Link>)}</div></section>)}</div>
|
||||||
{!filtered.length && <div className="catalog-empty"><Sparkles size={18} /> No tools match your search.</div>}
|
{!filtered.length && <div className="catalog-empty"><Sparkles size={18} /> No tools match your search.</div>}
|
||||||
<footer className="catalog-footer">ALL OPERATIONS RUN LOCALLY <span>•</span> YOUR FILES NEVER LEAVE THIS DEVICE</footer>
|
<footer className="catalog-footer">ALL OPERATIONS RUN LOCALLY <span>•</span> YOUR FILES NEVER LEAVE THIS DEVICE</footer>
|
||||||
</main>
|
</div></main>
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+4
-4
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import TopBar from '@/components/top-bar'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { ArrowDownToLine, Check, ChevronDown, CircleHelp, Download, GripVertical, Link2, Moon, MoreHorizontal, Plus, RotateCcw, Settings2, SlidersHorizontal, Sun, Upload, X } from 'lucide-react'
|
import { ArrowDownToLine, Check, ChevronDown, Download, GripVertical, Link2, MoreHorizontal, Plus, RotateCcw, Settings2, SlidersHorizontal, Upload, X } from 'lucide-react'
|
||||||
|
|
||||||
const initialSteps = [
|
const initialSteps = [
|
||||||
{ id: 1, title: 'Gradient background', type: 'BACKGROUND' },
|
{ id: 1, title: 'Gradient background', type: 'BACKGROUND' },
|
||||||
@@ -20,14 +21,13 @@ export default function Page() {
|
|||||||
const [radius, setRadius] = useState(18)
|
const [radius, setRadius] = useState(18)
|
||||||
const [outline, setOutline] = useState(2)
|
const [outline, setOutline] = useState(2)
|
||||||
const [language, setLanguage] = useState('RU')
|
const [language, setLanguage] = useState('RU')
|
||||||
const [dark, setDark] = useState(false)
|
|
||||||
const [showIntermediate, setShowIntermediate] = useState(true)
|
const [showIntermediate, setShowIntermediate] = useState(true)
|
||||||
const [gradient, setGradient] = useState('#DCEBFF')
|
const [gradient, setGradient] = useState('#DCEBFF')
|
||||||
const gradientStyle = useMemo(() => ({ background: `linear-gradient(${angle}deg, ${gradient}, #8BC8F5)` }), [angle, gradient])
|
const gradientStyle = useMemo(() => ({ background: `linear-gradient(${angle}deg, ${gradient}, #8BC8F5)` }), [angle, gradient])
|
||||||
const finalStyle = { ...gradientStyle, borderRadius: radius, boxShadow: `0 0 0 ${outline}px #16202B` }
|
const finalStyle = { ...gradientStyle, borderRadius: radius, boxShadow: `0 0 0 ${outline}px #16202B` }
|
||||||
|
|
||||||
return <main className={dark ? 'app-shell dark-mode' : 'app-shell'}>
|
return <main className="app-shell">
|
||||||
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ DEMO</span></div><div className="top-actions"><span className="status"><i /> AUTO PIPELINE</span><button className="icon-btn" aria-label="Help"><CircleHelp size={17} /></button><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button><div className="language"><button className={language === 'RU' ? 'active' : ''} onClick={() => setLanguage('RU')}>RU</button><button className={language === 'EN' ? 'active' : ''} onClick={() => setLanguage('EN')}>EN</button></div></div></header>
|
<TopBar section="WORKSPACE" status="AUTO PIPELINE" />
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<section className="workspace"><div className="eyebrow">PNG PROCESSING <span>/</span> WORKSPACE</div><div className="title-row"><div><h1>Build your image pipeline.</h1><p className="lede">Chain simple tools together. Every change is processed automatically and previewed at each stage.</p></div><div className="file-chip"><Upload size={15} /><span>source.png</span><b>1.8 MB</b></div></div>
|
<section className="workspace"><div className="eyebrow">PNG PROCESSING <span>/</span> WORKSPACE</div><div className="title-row"><div><h1>Build your image pipeline.</h1><p className="lede">Chain simple tools together. Every change is processed automatically and previewed at each stage.</p></div><div className="file-chip"><Upload size={15} /><span>source.png</span><b>1.8 MB</b></div></div>
|
||||||
<div className="pipeline-head"><div><span className="label">PROCESSING PIPELINE</span><strong>{steps.length} active steps <em>• LIVE</em></strong></div><button className="add-btn" onClick={() => setSteps([...steps, { id: Date.now(), title: 'New adjustment', type: 'STYLE' }])}><Plus size={15} /> Add tool</button></div>
|
<div className="pipeline-head"><div><span className="label">PROCESSING PIPELINE</span><strong>{steps.length} active steps <em>• LIVE</em></strong></div><button className="add-btn" onClick={() => setSteps([...steps, { id: Date.now(), title: 'New adjustment', type: 'STYLE' }])}><Plus size={15} /> Add tool</button></div>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { CircleHelp, Moon, Sun } from "lucide-react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
["Workspace", "/"],
|
||||||
|
["Catalog", "/easy-png-tools/list-tools"],
|
||||||
|
["Gradient", "/easy-png-tools/gradient"],
|
||||||
|
["Background remover", "/easy-png-tools/background-remover"],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function TopBar({ section = "WORKSPACE", status = "LOCAL MODE / READY" }: { section?: string; status?: string }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [dark, setDark] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem("ep-theme")
|
||||||
|
const isDark = stored === "dark"
|
||||||
|
setDark(isDark)
|
||||||
|
document.documentElement.classList.toggle("dark-mode", isDark)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = !dark
|
||||||
|
setDark(next)
|
||||||
|
document.documentElement.classList.toggle("dark-mode", next)
|
||||||
|
window.localStorage.setItem("ep-theme", next ? "dark" : "light")
|
||||||
|
}
|
||||||
|
|
||||||
|
return <header className="topbar"><Link href="/" className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ {section}</span></Link><nav className="top-nav" aria-label="Primary navigation">{links.map(([label, href]) => <Link key={href} className={pathname === href ? "active" : ""} href={href}>{label}</Link>)}</nav><div className="top-actions"><span className="status"><i /> {status}</span><button className="icon-btn" aria-label="Help"><CircleHelp size={17} /></button><button className="icon-btn" aria-label="Toggle theme" onClick={toggleTheme}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button><div className="language"><button className="active">RU</button><span>/</span><button>EN</button></div></div></header>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopBar
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"html": "node extract-static.mjs .next\\server\\app ..\\refs-html",
|
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+1
-4
@@ -4,9 +4,6 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
|
||||||
hono: 4.12.25
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -268,7 +265,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: 4.12.25
|
hono: ^4
|
||||||
|
|
||||||
'@img/colour@1.1.0':
|
'@img/colour@1.1.0':
|
||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -7,6 +7,31 @@ import { join, dirname, basename } from 'node:path';
|
|||||||
const root = process.argv[2];
|
const root = process.argv[2];
|
||||||
const dest = process.argv[3];
|
const dest = process.argv[3];
|
||||||
|
|
||||||
|
// --- inject a minimal theme toggle (extraction strips all <script> tags, so the
|
||||||
|
// ref pages would otherwise have no working dark-mode switch) ---
|
||||||
|
const THEME_TOGGLE = `
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.querySelector('button[aria-label="Toggle theme"]');
|
||||||
|
var shell = document.querySelector('.app-shell');
|
||||||
|
if (!btn || window.__themeToggleInjected) return;
|
||||||
|
window.__themeToggleInjected = true;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
document.documentElement.classList.toggle('dark-mode');
|
||||||
|
if (shell) shell.classList.toggle('dark-mode');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
|
||||||
|
function injectThemeToggle(html) {
|
||||||
|
if (html.includes('__themeToggleInjected')) return html;
|
||||||
|
const tag = '</body>';
|
||||||
|
if (html.includes(tag)) return html.replace(tag, THEME_TOGGLE + '\n' + tag);
|
||||||
|
if (html.includes('</html>')) return html.replace('</html>', THEME_TOGGLE + '\n</html>');
|
||||||
|
return html + '\n' + THEME_TOGGLE;
|
||||||
|
}
|
||||||
|
|
||||||
// --- CSS: find the compiled chunk and pretty-print it ---
|
// --- CSS: find the compiled chunk and pretty-print it ---
|
||||||
const cssChunks = join(root, '..', '..', 'static', 'chunks');
|
const cssChunks = join(root, '..', '..', 'static', 'chunks');
|
||||||
const cssFile = readdirSync(cssChunks)
|
const cssFile = readdirSync(cssChunks)
|
||||||
@@ -110,6 +135,7 @@ for (const [src, out] of pages) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
html = fmtHtml(html);
|
html = fmtHtml(html);
|
||||||
|
html = injectThemeToggle(html);
|
||||||
|
|
||||||
const file = join(dest, out);
|
const file = join(dest, out);
|
||||||
mkdirSync(dirname(file), { recursive: true });
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
@@ -6,3 +6,4 @@ pnpm-lock.yaml
|
|||||||
static
|
static
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
audit
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,7 @@
|
|||||||
# sv
|
# sv
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
Everything you need to build a Svelte project, powered by
|
||||||
|
[`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
## Creating a project
|
## Creating a project
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ pnpm dlx sv@0.17.0 create --template minimal --types ts --install pnpm web
|
|||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
Once you've created a project and installed dependencies with `npm install` (or
|
||||||
|
`pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -39,4 +41,5 @@ npm run build
|
|||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
> To deploy your app, you may need to install an
|
||||||
|
> [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
|
|||||||
+112
-487
File diff suppressed because it is too large
Load Diff
+109
-202
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
vs C:\+XBOCTuK\+life_projects\easy-png-tools\refs-html\demo.html
|
vs C:\+XBOCTuK\+life_projects\easy-png-tools\refs-html\demo.html
|
||||||
|
|
||||||
_2026-08-27T18:45:14.461Z_
|
_2026-08-28T07:02:47.972Z_
|
||||||
|
|
||||||
## Сводка
|
## Сводка
|
||||||
|
|
||||||
- Токены light: **12** расх.
|
- Токены light: **10** расх.
|
||||||
- Токены dark: **18** расх.
|
- Токены dark: **16** расх.
|
||||||
- Элементы (стиль/геометрия): **48** расх.
|
- Элементы (стиль/геометрия): **47** расх.
|
||||||
- Только у нас: 14, только в рефе: 7
|
- Только у нас: 14, только в рефе: 7
|
||||||
|
|
||||||
## Токены — Light
|
## Токены — Light
|
||||||
@@ -17,8 +17,6 @@ _2026-08-27T18:45:14.461Z_
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--danger` | #e5484d | — |
|
| `--danger` | #e5484d | — |
|
||||||
| `--success` | #25a96a | — |
|
| `--success` | #25a96a | — |
|
||||||
| `--font-sans` | "IBM Plex Sans", system-ui, -apple-system, sans-serif | "IBM Plex Sans", sans-serif |
|
|
||||||
| `--font-mono` | "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace | "IBM Plex Mono", monospace |
|
|
||||||
| `--text-sm--line-height` | — | calc(1.25 / .875) |
|
| `--text-sm--line-height` | — | calc(1.25 / .875) |
|
||||||
| `--text-xs--line-height` | — | calc(1 / .75) |
|
| `--text-xs--line-height` | — | calc(1 / .75) |
|
||||||
| `--font-weight-medium` | — | 500 |
|
| `--font-weight-medium` | — | 500 |
|
||||||
@@ -40,8 +38,6 @@ _2026-08-27T18:45:14.461Z_
|
|||||||
| `--foreground` | #e8eef2 | #17212b |
|
| `--foreground` | #e8eef2 | #17212b |
|
||||||
| `--success` | #25a96a | — |
|
| `--success` | #25a96a | — |
|
||||||
| `--line` | #33414c | #cbd3da |
|
| `--line` | #33414c | #cbd3da |
|
||||||
| `--font-sans` | "IBM Plex Sans", system-ui, -apple-system, sans-serif | "IBM Plex Sans", sans-serif |
|
|
||||||
| `--font-mono` | "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace | "IBM Plex Mono", monospace |
|
|
||||||
| `--text-sm--line-height` | — | calc(1.25 / .875) |
|
| `--text-sm--line-height` | — | calc(1.25 / .875) |
|
||||||
| `--text-xs--line-height` | — | calc(1 / .75) |
|
| `--text-xs--line-height` | — | calc(1 / .75) |
|
||||||
| `--font-weight-medium` | — | 500 |
|
| `--font-weight-medium` | — | 500 |
|
||||||
@@ -51,103 +47,25 @@ _2026-08-27T18:45:14.461Z_
|
|||||||
| `--radius-lg` | — | .5rem |
|
| `--radius-lg` | — | .5rem |
|
||||||
| `--text-sm` | — | .875rem |
|
| `--text-sm` | — | .875rem |
|
||||||
|
|
||||||
## Расхождения элементов (топ 48)
|
## Расхождения элементов (топ 47)
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(3)` — "BACKGROUND"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **color**: `rgb(23, 105, 210)` → `rgb(109, 120, 131)`
|
|
||||||
- **padding**: `2.4px 7.2px` → `0px`
|
|
||||||
- **letterSpacing**: `0.8px` → `1.2px`
|
|
||||||
- **borderRadius**: `4px` → `0px`
|
|
||||||
- **display**: `flex` → `inline`
|
|
||||||
- **gap**: `4.8px` → `normal`
|
|
||||||
- **rect**: `116,507 84x20` → `143,489 67x12`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(3)` — "TRANSFORM"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **color**: `rgb(23, 105, 210)` → `rgb(109, 120, 131)`
|
|
||||||
- **padding**: `2.4px 7.2px` → `0px`
|
|
||||||
- **letterSpacing**: `0.8px` → `1.2px`
|
|
||||||
- **borderRadius**: `4px` → `0px`
|
|
||||||
- **display**: `flex` → `inline`
|
|
||||||
- **gap**: `4.8px` → `normal`
|
|
||||||
- **rect**: `116,670 78x20` → `143,651 60x12`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(3)` — "STYLE"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **color**: `rgb(23, 105, 210)` → `rgb(109, 120, 131)`
|
|
||||||
- **padding**: `2.4px 7.2px` → `0px`
|
|
||||||
- **letterSpacing**: `0.8px` → `1.2px`
|
|
||||||
- **borderRadius**: `4px` → `0px`
|
|
||||||
- **display**: `flex` → `inline`
|
|
||||||
- **gap**: `4.8px` → `normal`
|
|
||||||
- **rect**: `116,785 50x20` → `143,799 34x12`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(2)>div:nth-child(4)>button:nth-child(1)` — "RU"
|
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(2)>div:nth-child(4)>button:nth-child(1)` — "RU"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `11px` → `10px`
|
|
||||||
- **color**: `rgb(255, 255, 255)` → `rgb(238, 241, 244)`
|
- **color**: `rgb(255, 255, 255)` → `rgb(238, 241, 244)`
|
||||||
- **backgroundColor**: `rgb(23, 105, 210)` → `rgb(23, 33, 43)`
|
- **backgroundColor**: `rgb(23, 105, 210)` → `rgb(23, 33, 43)`
|
||||||
- **padding**: `4.8px 11.2px` → `6px 9px`
|
- **padding**: `0px 11.2px` → `6px 9px`
|
||||||
- **letterSpacing**: `0.66px` → `normal`
|
- **letterSpacing**: `0.6px` → `normal`
|
||||||
- **rect**: `1307,20 37x24` → `1323,20 29x24`
|
- **display**: `flex` → `block`
|
||||||
|
- **rect**: `1309,17 36x30` → `1323,20 29x24`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>span:nth-child(1)` — "COLOR"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>span:nth-child(1)` — "COLOR"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontWeight**: `500` → `400`
|
- **fontWeight**: `500` → `400`
|
||||||
- **margin**: `0px` → `0px 0px 8px`
|
- **margin**: `0px` → `0px 0px 8px`
|
||||||
- **letterSpacing**: `1px` → `0.8px`
|
- **letterSpacing**: `1px` → `0.8px`
|
||||||
- **lineHeight**: `12px` → `normal`
|
- **lineHeight**: `12px` → `normal`
|
||||||
- **display**: `block` → `flex`
|
- **display**: `block` → `flex`
|
||||||
- **rect**: `71,576 203x12` → `143,546 189x12`
|
- **rect**: `71,575 203x12` → `143,546 189x12`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>span:nth-child(1)` — "SOURCE"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `9px`
|
|
||||||
- **fontWeight**: `500` → `400`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **letterSpacing**: `1px` → `normal`
|
|
||||||
- **lineHeight**: `12px` → `normal`
|
|
||||||
- **rect**: `806,320 262x12` → `808,663 30x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(2)>span:nth-child(1)` — "STEP 01"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `9px`
|
|
||||||
- **fontWeight**: `500` → `400`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **letterSpacing**: `1px` → `normal`
|
|
||||||
- **lineHeight**: `12px` → `normal`
|
|
||||||
- **rect**: `1094,320 262x12` → `1096,663 35x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(3)>span:nth-child(1)` — "STEP 02"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `9px`
|
|
||||||
- **fontWeight**: `500` → `400`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **letterSpacing**: `1px` → `normal`
|
|
||||||
- **lineHeight**: `12px` → `normal`
|
|
||||||
- **rect**: `806,752 262x12` → `808,1021 35x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(4)>span:nth-child(1)` — "STEP 03"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `9px`
|
|
||||||
- **fontWeight**: `500` → `400`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **letterSpacing**: `1px` → `normal`
|
|
||||||
- **lineHeight**: `12px` → `normal`
|
|
||||||
- **rect**: `1094,752 262x12` → `1096,1021 35x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(5)>span:nth-child(1)` — "FINAL OUTPUT"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `9px`
|
|
||||||
- **fontWeight**: `500` → `400`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **letterSpacing**: `1px` → `normal`
|
|
||||||
- **lineHeight**: `12px` → `normal`
|
|
||||||
- **rect**: `806,1183 262x12` → `808,1378 59x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>button:nth-child(2)>span:nth-child(2)>span:nth-child(1)` — "1.2 MB"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>button:nth-child(2)>span:nth-child(2)>span:nth-child(1)` — "1.2 MB"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `11px`
|
- **fontSize**: `10px` → `11px`
|
||||||
- **color**: `rgb(255, 255, 255)` → `rgb(23, 33, 43)`
|
- **color**: `rgb(255, 255, 255)` → `rgb(23, 33, 43)`
|
||||||
- **margin**: `0px` → `5px 0px 0px`
|
- **margin**: `0px` → `5px 0px 0px`
|
||||||
@@ -155,196 +73,185 @@ _2026-08-27T18:45:14.461Z_
|
|||||||
- **rect**: `1090,240 36x13` → `1246,291 79x13`
|
- **rect**: `1090,240 36x13` → `1246,291 79x13`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(2)>div:nth-child(4)>button:nth-child(2)` — "EN"
|
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(2)>div:nth-child(4)>button:nth-child(2)` — "EN"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **padding**: `0px 11.2px` → `6px 9px`
|
||||||
- **fontSize**: `11px` → `10px`
|
- **letterSpacing**: `0.6px` → `normal`
|
||||||
- **padding**: `4.8px 11.2px` → `6px 9px`
|
- **display**: `flex` → `block`
|
||||||
- **letterSpacing**: `0.66px` → `normal`
|
- **rect**: `1345,17 37x30` → `1352,20 29x24`
|
||||||
- **rect**: `1344,20 38x24` → `1352,20 29x24`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(1)` — "100"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(1)` — "100"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **padding**: `0px 11.2px` → `0px`
|
||||||
- **fontSize**: `11px` → `10px`
|
- **letterSpacing**: `0.6px` → `normal`
|
||||||
- **padding**: `4.8px 11.2px` → `0px`
|
- **display**: `flex` → `block`
|
||||||
- **letterSpacing**: `0.66px` → `normal`
|
- **rect**: `491,596 42x30` → `520,567 46x30`
|
||||||
- **rect**: `491,597 44x24` → `520,567 46x30`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(2)` — "75"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(2)` — "75"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **padding**: `0px 11.2px` → `0px`
|
||||||
- **fontSize**: `11px` → `10px`
|
- **letterSpacing**: `0.6px` → `normal`
|
||||||
- **padding**: `4.8px 11.2px` → `0px`
|
- **display**: `flex` → `block`
|
||||||
- **letterSpacing**: `0.66px` → `normal`
|
- **rect**: `533,596 37x30` → `566,567 46x30`
|
||||||
- **rect**: `535,597 38x24` → `566,567 46x30`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(3)` — "50"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(3)` — "50"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **padding**: `0px 11.2px` → `0px`
|
||||||
- **fontSize**: `11px` → `10px`
|
- **letterSpacing**: `0.6px` → `normal`
|
||||||
- **padding**: `4.8px 11.2px` → `0px`
|
- **display**: `flex` → `block`
|
||||||
- **letterSpacing**: `0.66px` → `normal`
|
- **rect**: `569,596 37x30` → `612,567 46x30`
|
||||||
- **rect**: `573,597 38x24` → `612,567 46x30`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(4)` — "25"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(4)` — "25"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **padding**: `0px 11.2px` → `0px`
|
||||||
- **fontSize**: `11px` → `10px`
|
- **letterSpacing**: `0.6px` → `normal`
|
||||||
- **padding**: `4.8px 11.2px` → `0px`
|
- **display**: `flex` → `block`
|
||||||
- **letterSpacing**: `0.66px` → `normal`
|
- **rect**: `606,596 37x30` → `658,567 46x30`
|
||||||
- **rect**: `611,597 38x24` → `658,567 46x30`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(2)>div:nth-child(2)>label:nth-child(2)>span:nth-child(1)` — "Preserve aspect ratio"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(2)>div:nth-child(2)>label:nth-child(2)>span:nth-child(1)` — "Preserve aspect ratio"
|
||||||
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif` → `"IBM Plex Sans", sans-serif`
|
|
||||||
- **fontSize**: `13.6px` → `16px`
|
- **fontSize**: `13.6px` → `16px`
|
||||||
- **lineHeight**: `normal` → `24px`
|
- **lineHeight**: `normal` → `24px`
|
||||||
- **display**: `block` → `inline`
|
- **display**: `block` → `inline`
|
||||||
- **rect**: `399,1036 129x18` → `432,1021 156x17`
|
- **rect**: `399,1032 129x18` → `432,1021 156x17`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(2)` — "01"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **padding**: `0px` → `17px 0px 0px 15px`
|
|
||||||
- **rect**: `95,510 13x14` → `59,466 46x150`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(4)` — "Gradient background"
|
|
||||||
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `14.4px` → `15px`
|
|
||||||
- **margin**: `0px` → `5px 0px 18px`
|
|
||||||
- **rect**: `208,508 467x19` → `143,510 157x18`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(2)` — "02"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **padding**: `0px` → `17px 0px 0px 15px`
|
|
||||||
- **rect**: `95,673 13x14` → `59,628 46x136`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(4)` — "Remove background"
|
|
||||||
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `14.4px` → `15px`
|
|
||||||
- **margin**: `0px` → `5px 0px 18px`
|
|
||||||
- **rect**: `201,671 473x19` → `143,672 140x18`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(2)` — "03"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **padding**: `0px` → `17px 0px 0px 15px`
|
|
||||||
- **rect**: `95,788 13x14` → `59,776 46x150`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(4)` — "Add outline"
|
|
||||||
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `14.4px` → `15px`
|
|
||||||
- **margin**: `0px` → `5px 0px 18px`
|
|
||||||
- **rect**: `174,785 501x19` → `143,820 91x18`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(1)>span:nth-child(2)` — "04"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **color**: `rgb(109, 120, 131)` → `rgb(23, 105, 210)`
|
|
||||||
- **padding**: `0px` → `17px 0px 0px 15px`
|
|
||||||
- **rect**: `95,951 13x14` → `59,938 46x142`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(1)>span:nth-child(4)` — "Round corners"
|
|
||||||
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `14.4px` → `15px`
|
|
||||||
- **margin**: `0px` → `5px 0px 18px`
|
|
||||||
- **rect**: `174,948 501x19` → `143,982 107x18`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(1)>span:nth-child(1)` — "DIMENSIONS"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(1)>span:nth-child(1)` — "DIMENSIONS"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `9px`
|
- **fontSize**: `10px` → `9px`
|
||||||
- **letterSpacing**: `1px` → `normal`
|
- **letterSpacing**: `1px` → `normal`
|
||||||
- **rect**: `1150,215 70x13` → `1073,271 79x10`
|
- **rect**: `1150,215 70x13` → `1073,271 79x10`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>span:nth-child(1)` — "FORMAT"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>span:nth-child(1)` — "FORMAT"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `9px`
|
- **fontSize**: `10px` → `9px`
|
||||||
- **letterSpacing**: `1px` → `normal`
|
- **letterSpacing**: `1px` → `normal`
|
||||||
- **rect**: `1150,239 42x13` → `1159,271 79x10`
|
- **rect**: `1150,239 42x13` → `1159,271 79x10`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(3)>span:nth-child(1)` — "SIZE"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(3)>span:nth-child(1)` — "SIZE"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **fontSize**: `10px` → `9px`
|
- **fontSize**: `10px` → `9px`
|
||||||
- **letterSpacing**: `1px` → `normal`
|
- **letterSpacing**: `1px` → `normal`
|
||||||
- **rect**: `1150,264 28x13` → `1246,271 79x10`
|
- **rect**: `1150,264 28x13` → `1246,271 79x10`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(1)>span:nth-child(2)` — "easy-png-tools"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>span:nth-child(1)` — "SOURCE"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **fontSize**: `10px` → `9px`
|
||||||
- **color**: `rgb(0, 0, 0)` → `rgb(23, 33, 43)`
|
- **letterSpacing**: `0.8px` → `normal`
|
||||||
- **rect**: `98,23 118x18` → `98,23 108x17`
|
- **rect**: `806,320 262x13` → `808,663 30x10`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(2)>span:nth-child(1)` — "STEP 01"
|
||||||
|
- **fontSize**: `10px` → `9px`
|
||||||
|
- **letterSpacing**: `0.8px` → `normal`
|
||||||
|
- **rect**: `1094,320 262x13` → `1096,663 35x10`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(3)>span:nth-child(1)` — "STEP 02"
|
||||||
|
- **fontSize**: `10px` → `9px`
|
||||||
|
- **letterSpacing**: `0.8px` → `normal`
|
||||||
|
- **rect**: `806,753 262x13` → `808,1021 35x10`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(4)>span:nth-child(1)` — "STEP 03"
|
||||||
|
- **fontSize**: `10px` → `9px`
|
||||||
|
- **letterSpacing**: `0.8px` → `normal`
|
||||||
|
- **rect**: `1094,753 262x13` → `1096,1021 35x10`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(5)>span:nth-child(1)` — "FINAL OUTPUT"
|
||||||
|
- **fontSize**: `10px` → `9px`
|
||||||
|
- **letterSpacing**: `0.8px` → `normal`
|
||||||
|
- **rect**: `806,1185 262x13` → `808,1378 59x10`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "/"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "/"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **color**: `rgb(23, 105, 210)` → `rgb(109, 120, 131)`
|
- **color**: `rgb(23, 105, 210)` → `rgb(109, 120, 131)`
|
||||||
- **rect**: `173,124 7x13` → `165,124 13x12`
|
- **rect**: `173,124 7x13` → `165,124 13x12`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(1)>h1:nth-child(1)` — "Build your image pipeline."
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(1)>h1:nth-child(1)` — "Build your image pipeline."
|
||||||
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif` → `"IBM Plex Sans", sans-serif`
|
|
||||||
- **lineHeight**: `normal` → `57.6px`
|
- **lineHeight**: `normal` → `57.6px`
|
||||||
- **rect**: `58,155 474x150` → `58,154 482x115`
|
- **rect**: `58,155 474x150` → `58,154 482x115`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(2)` — "01"
|
||||||
|
- **padding**: `0px` → `17px 0px 0px 15px`
|
||||||
|
- **rect**: `95,510 13x14` → `59,466 46x150`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(3)` — "BACKGROUND"
|
||||||
|
- **display**: `block` → `inline`
|
||||||
|
- **rect**: `116,510 72x13` → `143,489 67x12`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(4)` — "Gradient background"
|
||||||
|
- **margin**: `0px` → `5px 0px 18px`
|
||||||
|
- **rect**: `196,507 171x19` → `143,510 157x18`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(2)` — "02"
|
||||||
|
- **padding**: `0px` → `17px 0px 0px 15px`
|
||||||
|
- **rect**: `95,672 13x14` → `59,628 46x136`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(3)` — "TRANSFORM"
|
||||||
|
- **display**: `block` → `inline`
|
||||||
|
- **rect**: `116,672 65x13` → `143,651 60x12`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(4)` — "Remove background"
|
||||||
|
- **margin**: `0px` → `5px 0px 18px`
|
||||||
|
- **rect**: `189,669 153x19` → `143,672 140x18`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(2)` — "03"
|
||||||
|
- **padding**: `0px` → `17px 0px 0px 15px`
|
||||||
|
- **rect**: `95,786 13x14` → `59,776 46x150`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(3)` — "STYLE"
|
||||||
|
- **display**: `block` → `inline`
|
||||||
|
- **rect**: `116,786 36x13` → `143,799 34x12`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(4)` — "Add outline"
|
||||||
|
- **margin**: `0px` → `5px 0px 18px`
|
||||||
|
- **rect**: `160,783 99x19` → `143,820 91x18`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(1)>span:nth-child(2)` — "04"
|
||||||
|
- **padding**: `0px` → `17px 0px 0px 15px`
|
||||||
|
- **rect**: `95,948 13x14` → `59,938 46x142`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(1)>span:nth-child(4)` — "Round corners"
|
||||||
|
- **margin**: `0px` → `5px 0px 18px`
|
||||||
|
- **rect**: `160,945 117x19` → `143,982 107x18`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(1)>span:nth-child(2)` — "1200 × 800 px"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(1)>span:nth-child(2)` — "1200 × 800 px"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **margin**: `0px` → `5px 0px 0px`
|
- **margin**: `0px` → `5px 0px 0px`
|
||||||
- **rect**: `1236,214 86x14` → `1073,291 79x13`
|
- **rect**: `1236,214 86x14` → `1073,291 79x13`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>span:nth-child(2)` — "PNG-24"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>span:nth-child(2)` — "PNG-24"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **margin**: `0px` → `5px 0px 0px`
|
- **margin**: `0px` → `5px 0px 0px`
|
||||||
- **rect**: `1282,238 40x14` → `1159,291 79x13`
|
- **rect**: `1282,238 40x14` → `1159,291 79x13`
|
||||||
|
|
||||||
|
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(1)>span:nth-child(2)` — "easy-png-tools"
|
||||||
|
- **rect**: `98,23 118x18` → `98,23 108x17`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(1)>p:nth-child(2)` — "Chain simple tools together. Every chang"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(1)>p:nth-child(2)` — "Chain simple tools together. Every chang"
|
||||||
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif` → `"IBM Plex Sans", sans-serif`
|
|
||||||
- **rect**: `58,321 474x51` → `58,285 482x51`
|
- **rect**: `58,321 474x51` → `58,285 482x51`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "source.png"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "source.png"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **rect**: `597,347 66x14` → `606,311 60x13`
|
- **rect**: `597,347 66x14` → `606,311 60x13`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(2)>b:nth-child(3)` — "1.8 MB"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(2)>b:nth-child(3)` — "1.8 MB"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **rect**: `671,347 40x14` → `674,311 36x13`
|
- **rect**: `671,347 40x14` → `674,311 36x13`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(3)>div:nth-child(1)>span:nth-child(1)` — "PROCESSING PIPELINE"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(3)>div:nth-child(1)>span:nth-child(1)` — "PROCESSING PIPELINE"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **rect**: `58,434 137x13` → `58,400 127x12`
|
- **rect**: `58,434 137x13` → `58,400 127x12`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(3)>div:nth-child(1)>strong:nth-child(2)>em:nth-child(1)` — "• LIVE"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(3)>div:nth-child(1)>strong:nth-child(2)>em:nth-child(1)` — "• LIVE"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **rect**: `184,457 36x13` → `173,425 33x12`
|
- **rect**: `184,457 36x13` → `173,425 33x12`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "PIPELINE OUTPUTS"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "PIPELINE OUTPUTS"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **rect**: `799,177 58x33` → `799,188 54x36`
|
- **rect**: `799,177 58x33` → `799,188 54x36`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(1)>strong:nth-child(2)` — "Visual history"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(1)>strong:nth-child(2)` — "Visual history"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
- **rect**: `799,216 93x36` → `799,233 87x34`
|
- **rect**: `799,216 93x36` → `799,233 87x34`
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "PNG"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "PNG"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **rect**: `923,499 28x19` → `923,478 27x18`
|
||||||
- **rect**: `923,498 28x19` → `923,478 27x18`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "original.png · 1200 × 800"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "original.png · 1200 × 800"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **rect**: `925,707 135x11` → `942,663 124x10`
|
||||||
- **rect**: `925,706 135x11` → `942,663 124x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(2)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "gradient applied"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(2)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "gradient applied"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **rect**: `1262,707 86x11` → `1275,663 79x10`
|
||||||
- **rect**: `1262,706 86x11` → `1275,663 79x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "background removed"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "background removed"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **rect**: `962,1140 97x11` → `977,1021 89x10`
|
||||||
- **rect**: `962,1138 97x11` → `977,1021 89x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(4)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "outline added"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(4)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "outline added"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **rect**: `1278,1140 70x11` → `1290,1021 64x10`
|
||||||
- **rect**: `1278,1138 70x11` → `1290,1021 64x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(5)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "ready · PNG-24"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(5)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "ready · PNG-24"
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
- **rect**: `984,1572 76x11` → `997,1378 69x10`
|
||||||
- **rect**: `984,1569 76x11` → `997,1378 69x10`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>p:nth-child(3)` — "Output is generated in-browser. Your fil"
|
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>p:nth-child(3)` — "Output is generated in-browser. Your fil"
|
||||||
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif` → `"IBM Plex Sans", sans-serif`
|
- **rect**: `799,1627 565x17` → `799,1425 565x17`
|
||||||
- **rect**: `799,1624 565x17` → `799,1425 565x17`
|
|
||||||
|
|
||||||
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "EP"
|
|
||||||
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace` → `"IBM Plex Mono", monospace`
|
|
||||||
|
|
||||||
## Только у нас (структурно)
|
## Только у нас (структурно)
|
||||||
|
|
||||||
|
|||||||
+18
-9
@@ -10,6 +10,20 @@ import tseslint from "typescript-eslint";
|
|||||||
// включается на весь код (см. план-redesign §10, шаг 6).
|
// включается на весь код (см. план-redesign §10, шаг 6).
|
||||||
const newCode = ["**/src/lib/components/kit/**", "**/src/routes/preview/**"];
|
const newCode = ["**/src/lib/components/kit/**", "**/src/routes/preview/**"];
|
||||||
|
|
||||||
|
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
|
||||||
|
const jsRecommended = Array.isArray(js.configs.recommended)
|
||||||
|
? js.configs.recommended
|
||||||
|
: [js.configs.recommended];
|
||||||
|
const svelteRecommended = Array.isArray(svelte.configs["flat/recommended"])
|
||||||
|
? svelte.configs["flat/recommended"]
|
||||||
|
: [svelte.configs["flat/recommended"]];
|
||||||
|
// Svelte-рекомендации применяем только к .svelte-файлам нового кода, иначе
|
||||||
|
// svelte-eslint-parser "съедает" обычные .ts в тех же папках (напр. +page.ts).
|
||||||
|
const svelteFiles = [
|
||||||
|
"**/src/lib/components/kit/**/*.svelte",
|
||||||
|
"**/src/routes/preview/**/*.svelte",
|
||||||
|
];
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
@@ -68,15 +82,10 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
// Полные recommended-наборы — только на новый код.
|
// Полные recommended-наборы — только на новый код.
|
||||||
...[
|
...[
|
||||||
...(Array.isArray(js.configs.recommended)
|
...jsRecommended.map((cfg) => ({ ...cfg, files: newCode })),
|
||||||
? js.configs.recommended
|
...tseslint.configs.recommended.map((cfg) => ({ ...cfg, files: newCode })),
|
||||||
: [js.configs.recommended]),
|
...svelteRecommended.map((cfg) => ({ ...cfg, files: svelteFiles })),
|
||||||
...tseslint.configs.recommended,
|
],
|
||||||
...svelte.configs["flat/recommended"],
|
|
||||||
].map((cfg) => ({
|
|
||||||
...cfg,
|
|
||||||
files: newCode,
|
|
||||||
})),
|
|
||||||
|
|
||||||
// В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и
|
// В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и
|
||||||
// наш AGENTS.md), поэтому prefer-const на них — ложноположительный.
|
// наш AGENTS.md), поэтому prefer-const на них — ложноположительный.
|
||||||
|
|||||||
+67
-32
@@ -38,7 +38,7 @@ const FIELDS = [
|
|||||||
"lineHeight",
|
"lineHeight",
|
||||||
"borderRadius",
|
"borderRadius",
|
||||||
"display",
|
"display",
|
||||||
"gap"
|
"gap",
|
||||||
];
|
];
|
||||||
|
|
||||||
const waitFor = async (url, ms = 60000) => {
|
const waitFor = async (url, ms = 60000) => {
|
||||||
@@ -58,7 +58,8 @@ const snapshot = (page, url) =>
|
|||||||
const readTokens = () => {
|
const readTokens = () => {
|
||||||
const s = getComputedStyle(document.documentElement);
|
const s = getComputedStyle(document.documentElement);
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const k of s) if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
|
for (const k of s)
|
||||||
|
if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
const tokensLight = readTokens();
|
const tokensLight = readTokens();
|
||||||
@@ -85,14 +86,19 @@ const snapshot = (page, url) =>
|
|||||||
"lineHeight",
|
"lineHeight",
|
||||||
"borderRadius",
|
"borderRadius",
|
||||||
"display",
|
"display",
|
||||||
"gap"
|
"gap",
|
||||||
])
|
])
|
||||||
o[f] = s[f];
|
o[f] = s[f];
|
||||||
return o;
|
return o;
|
||||||
};
|
};
|
||||||
const rect = (el) => {
|
const rect = (el) => {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
|
return {
|
||||||
|
x: Math.round(r.x),
|
||||||
|
y: Math.round(r.y),
|
||||||
|
w: Math.round(r.width),
|
||||||
|
h: Math.round(r.height),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
const walk = (el, path) => {
|
const walk = (el, path) => {
|
||||||
if (["SCRIPT", "STYLE", "NOSCRIPT"].includes(el.tagName)) return [];
|
if (["SCRIPT", "STYLE", "NOSCRIPT"].includes(el.tagName)) return [];
|
||||||
@@ -101,12 +107,18 @@ const snapshot = (page, url) =>
|
|||||||
out.push({
|
out.push({
|
||||||
path,
|
path,
|
||||||
tag: el.tagName.toLowerCase(),
|
tag: el.tagName.toLowerCase(),
|
||||||
text: el.children.length === 0 ? el.textContent.trim().slice(0, 40) : "",
|
text:
|
||||||
|
el.children.length === 0 ? el.textContent.trim().slice(0, 40) : "",
|
||||||
rect: rect(el),
|
rect: rect(el),
|
||||||
style: sig(el)
|
style: sig(el),
|
||||||
});
|
});
|
||||||
for (let i = 0; i < kids.length; i++)
|
for (let i = 0; i < kids.length; i++)
|
||||||
out.push(...walk(kids[i], `${path}>${kids[i].tagName.toLowerCase()}:nth-child(${i + 1})`));
|
out.push(
|
||||||
|
...walk(
|
||||||
|
kids[i],
|
||||||
|
`${path}>${kids[i].tagName.toLowerCase()}:nth-child(${i + 1})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
return { tokensLight, tokensDark, tree: walk(document.body, "body") };
|
return { tokensLight, tokensDark, tree: walk(document.body, "body") };
|
||||||
@@ -120,13 +132,16 @@ async function main() {
|
|||||||
cwd: WEB,
|
cwd: WEB,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
shell: true,
|
shell: true,
|
||||||
detached: process.platform !== "win32"
|
detached: process.platform !== "win32",
|
||||||
});
|
});
|
||||||
await waitFor(OURS);
|
await waitFor(OURS);
|
||||||
}
|
}
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
const page = await browser.newPage({
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await page.goto(OURS, { waitUntil: "load" });
|
await page.goto(OURS, { waitUntil: "load" });
|
||||||
await page.evaluate(() => document.fonts.ready);
|
await page.evaluate(() => document.fonts.ready);
|
||||||
@@ -154,7 +169,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
const tokenDiffs = {
|
const tokenDiffs = {
|
||||||
light: tokDiff(ours.tokensLight, ref.tokensLight),
|
light: tokDiff(ours.tokensLight, ref.tokensLight),
|
||||||
dark: tokDiff(ours.tokensDark, ref.tokensDark)
|
dark: tokDiff(ours.tokensDark, ref.tokensDark),
|
||||||
};
|
};
|
||||||
|
|
||||||
// element diff: совпадающие по тексту элементы (структуры разные,
|
// element diff: совпадающие по тексту элементы (структуры разные,
|
||||||
@@ -183,9 +198,15 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const dr = o.rect;
|
const dr = o.rect;
|
||||||
const rr = r.rect;
|
const rr = r.rect;
|
||||||
if (Math.abs(dr.x - rr.x) > 2 || Math.abs(dr.y - rr.y) > 2 || Math.abs(dr.w - rr.w) > 2 || Math.abs(dr.h - rr.h) > 2)
|
if (
|
||||||
|
Math.abs(dr.x - rr.x) > 2 ||
|
||||||
|
Math.abs(dr.y - rr.y) > 2 ||
|
||||||
|
Math.abs(dr.w - rr.w) > 2 ||
|
||||||
|
Math.abs(dr.h - rr.h) > 2
|
||||||
|
)
|
||||||
deltas.rect = { ours: dr, ref: rr };
|
deltas.rect = { ours: dr, ref: rr };
|
||||||
if (Object.keys(deltas).length) elementDiffs.push({ path: o.path, tag: o.tag, text: k, deltas });
|
if (Object.keys(deltas).length)
|
||||||
|
elementDiffs.push({ path: o.path, tag: o.tag, text: k, deltas });
|
||||||
}
|
}
|
||||||
for (const [k] of txtR) if (!txtO.has(k)) onlyRef.push(k);
|
for (const [k] of txtR) if (!txtO.has(k)) onlyRef.push(k);
|
||||||
|
|
||||||
@@ -202,16 +223,22 @@ async function main() {
|
|||||||
tokenDark: tokenDiffs.dark.length,
|
tokenDark: tokenDiffs.dark.length,
|
||||||
elements: elementDiffs.length,
|
elements: elementDiffs.length,
|
||||||
onlyOurs: onlyOurs.length,
|
onlyOurs: onlyOurs.length,
|
||||||
onlyRef: onlyRef.length
|
onlyRef: onlyRef.length,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
||||||
writeFileSync(resolve(WEB, "audit/audit-report.json"), JSON.stringify(report, null, 2));
|
writeFileSync(
|
||||||
writeFileSync(resolve(WEB, "audit/audit-report.md"), toMarkdown(report, ours, ref));
|
resolve(WEB, "audit/audit-report.json"),
|
||||||
|
JSON.stringify(report, null, 2),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
resolve(WEB, "audit/audit-report.md"),
|
||||||
|
toMarkdown(report, ours, ref),
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`audit done: tokens light/dark=${tokenDiffs.light.length}/${tokenDiffs.dark.length}, ` +
|
`audit done: tokens light/dark=${tokenDiffs.light.length}/${tokenDiffs.dark.length}, ` +
|
||||||
`elements=${elementDiffs.length}, onlyOurs=${onlyOurs.length}, onlyRef=${onlyRef.length}`
|
`elements=${elementDiffs.length}, onlyOurs=${onlyOurs.length}, onlyRef=${onlyRef.length}`,
|
||||||
);
|
);
|
||||||
console.log(`report: web/audit/audit-report.md`);
|
console.log(`report: web/audit/audit-report.md`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -219,7 +246,9 @@ async function main() {
|
|||||||
if (server) {
|
if (server) {
|
||||||
try {
|
try {
|
||||||
if (process.platform === "win32")
|
if (process.platform === "win32")
|
||||||
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], { stdio: "ignore" });
|
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
else server.kill("SIGTERM");
|
else server.kill("SIGTERM");
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -233,24 +262,25 @@ function toMarkdown(report, ours, ref) {
|
|||||||
rows.map((r) => `| \`${r.key}\` | ${r.ours} | ${r.ref} |`).join("\n")
|
rows.map((r) => `| \`${r.key}\` | ${r.ours} | ${r.ref} |`).join("\n")
|
||||||
: "_все совпадают_";
|
: "_все совпадают_";
|
||||||
const sorted = [...report.elementDiffs].sort(
|
const sorted = [...report.elementDiffs].sort(
|
||||||
(x, y) => Object.keys(y.deltas).length - Object.keys(x.deltas).length
|
(x, y) => Object.keys(y.deltas).length - Object.keys(x.deltas).length,
|
||||||
);
|
);
|
||||||
const elRows = sorted
|
const elRows = sorted
|
||||||
.slice(0, 100)
|
.slice(0, 100)
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const d = Object.entries(e.deltas)
|
const d = Object.entries(e.deltas)
|
||||||
.map(([k, v]) => {
|
.map(([k, v]) => {
|
||||||
if (k === "rect") {
|
if (k === "rect") {
|
||||||
const f = (r) => `${r.x},${r.y} ${r.w}x${r.h}`;
|
const f = (r) => `${r.x},${r.y} ${r.w}x${r.h}`;
|
||||||
return ` - **rect**: \`${f(v.ours)}\` → \`${f(v.ref)}\``;
|
return ` - **rect**: \`${f(v.ours)}\` → \`${f(v.ref)}\``;
|
||||||
}
|
}
|
||||||
return ` - **${k}**: \`${v.ours}\` → \`${v.ref}\``;
|
return ` - **${k}**: \`${v.ours}\` → \`${v.ref}\``;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
return `### \`${e.path}\`${e.text ? ` — "${e.text}"` : ""}\n${d}`;
|
return `### \`${e.path}\`${e.text ? ` — "${e.text}"` : ""}\n${d}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
return `# Style audit: ${report.ours}\n\nvs ${report.ref}\n\n_${report.generatedAt}_\n\n` +
|
return (
|
||||||
|
`# Style audit: ${report.ours}\n\nvs ${report.ref}\n\n_${report.generatedAt}_\n\n` +
|
||||||
`## Сводка\n\n- Токены light: **${report.counts.tokenLight}** расх.\n` +
|
`## Сводка\n\n- Токены light: **${report.counts.tokenLight}** расх.\n` +
|
||||||
`- Токены dark: **${report.counts.tokenDark}** расх.\n` +
|
`- Токены dark: **${report.counts.tokenDark}** расх.\n` +
|
||||||
`- Элементы (стиль/геометрия): **${report.counts.elements}** расх.\n` +
|
`- Элементы (стиль/геометрия): **${report.counts.elements}** расх.\n` +
|
||||||
@@ -259,10 +289,15 @@ function toMarkdown(report, ours, ref) {
|
|||||||
`## Токены — Dark\n\n${tokTable(report.tokenDiffs.dark)}\n\n` +
|
`## Токены — Dark\n\n${tokTable(report.tokenDiffs.dark)}\n\n` +
|
||||||
`## Расхождения элементов (топ ${Math.min(100, sorted.length)})\n\n${elRows}\n\n` +
|
`## Расхождения элементов (топ ${Math.min(100, sorted.length)})\n\n${elRows}\n\n` +
|
||||||
`## Только у нас (структурно)\n\n` +
|
`## Только у нас (структурно)\n\n` +
|
||||||
(report.onlyOurs.length ? report.onlyOurs.map((p) => `- \`${p}\``).join("\n") : "_—_") +
|
(report.onlyOurs.length
|
||||||
|
? report.onlyOurs.map((p) => `- \`${p}\``).join("\n")
|
||||||
|
: "_—_") +
|
||||||
`\n\n## Только в рефе (структурно)\n\n` +
|
`\n\n## Только в рефе (структурно)\n\n` +
|
||||||
(report.onlyRef.length ? report.onlyRef.map((p) => `- \`${p}\``).join("\n") : "_—_") +
|
(report.onlyRef.length
|
||||||
`\n`;
|
? report.onlyRef.map((p) => `- \`${p}\``).join("\n")
|
||||||
|
: "_—_") +
|
||||||
|
`\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
|
|||||||
+5
-4
@@ -39,11 +39,12 @@
|
|||||||
--control-max-width: 16rem;
|
--control-max-width: 16rem;
|
||||||
|
|
||||||
--font-sans:
|
--font-sans:
|
||||||
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||||
--font-mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
sans-serif;
|
||||||
|
--font-mono: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--bg: #12151a;
|
--bg: #12151a;
|
||||||
@@ -150,7 +151,7 @@ select {
|
|||||||
max-width: var(--control-max-width);
|
max-width: var(--control-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
input.control[type='number'] {
|
input.control[type="number"] {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -10,7 +10,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.css?raw' {
|
declare module "*.css?raw" {
|
||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -7,16 +7,16 @@
|
|||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('theme');
|
const saved = localStorage.getItem("theme");
|
||||||
const theme =
|
const theme =
|
||||||
saved === 'dark' || saved === 'light'
|
saved === "dark" || saved === "light"
|
||||||
? saved
|
? saved
|
||||||
: matchMedia('(prefers-color-scheme: dark)').matches
|
: matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
? 'dark'
|
? "dark"
|
||||||
: 'light';
|
: "light";
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
} catch {
|
} catch {
|
||||||
document.documentElement.dataset.theme = 'light';
|
document.documentElement.dataset.theme = "light";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+16
-16
@@ -1,24 +1,24 @@
|
|||||||
export type CategoryId =
|
export type CategoryId =
|
||||||
| 'convert'
|
| "convert"
|
||||||
| 'alpha'
|
| "alpha"
|
||||||
| 'color'
|
| "color"
|
||||||
| 'geometry'
|
| "geometry"
|
||||||
| 'analyze'
|
| "analyze"
|
||||||
| 'generate'
|
| "generate"
|
||||||
| 'filters'
|
| "filters"
|
||||||
| 'text';
|
| "text";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
|
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
|
||||||
* i18n: секция categories, ключ = CategoryId.
|
* i18n: секция categories, ключ = CategoryId.
|
||||||
*/
|
*/
|
||||||
export const CATEGORIES: readonly CategoryId[] = [
|
export const CATEGORIES: readonly CategoryId[] = [
|
||||||
'convert',
|
"convert",
|
||||||
'alpha',
|
"alpha",
|
||||||
'color',
|
"color",
|
||||||
'geometry',
|
"geometry",
|
||||||
'filters',
|
"filters",
|
||||||
'text',
|
"text",
|
||||||
'analyze',
|
"analyze",
|
||||||
'generate'
|
"generate",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from './ui/Button.svelte';
|
import Button from "./ui/Button.svelte";
|
||||||
import { downloadBlob, encode } from '$lib/core/io';
|
import { downloadBlob, encode } from "$lib/core/io";
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import type { OutputFormat } from '$lib/registry';
|
import type { OutputFormat } from "$lib/registry";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
image: PixelImage | null;
|
image: PixelImage | null;
|
||||||
@@ -21,9 +21,13 @@
|
|||||||
if (!image || !format || busy) return;
|
if (!image || !format || busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const raw = format.qualityParamId ? params[format.qualityParamId] : undefined;
|
const raw = format.qualityParamId
|
||||||
|
? params[format.qualityParamId]
|
||||||
|
: undefined;
|
||||||
const quality =
|
const quality =
|
||||||
typeof raw === 'number' ? Math.min(Math.max(raw, 1), 100) / 100 : undefined;
|
typeof raw === "number"
|
||||||
|
? Math.min(Math.max(raw, 1), 100) / 100
|
||||||
|
: undefined;
|
||||||
const blob = await encode(image, format.mime, quality);
|
const blob = await encode(image, format.mime, quality);
|
||||||
downloadBlob(blob, `${baseName}.${format.ext}`);
|
downloadBlob(blob, `${baseName}.${format.ext}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -39,8 +43,8 @@
|
|||||||
fullWidth
|
fullWidth
|
||||||
disabled={!image || !format}
|
disabled={!image || !format}
|
||||||
{busy}
|
{busy}
|
||||||
busyText={t('download.busy')}
|
busyText={t("download.busy")}
|
||||||
onclick={download}
|
onclick={download}
|
||||||
>
|
>
|
||||||
{t('download.file', { ext: format?.ext ?? 'png' })}
|
{t("download.file", { ext: format?.ext ?? "png" })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
import { isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
import { isSupportedImage, unsupportedImageError } from "$lib/core/io";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
let {
|
let {
|
||||||
onFile,
|
onFile,
|
||||||
onError,
|
onError,
|
||||||
label = t('dropZone.overlayDefault'),
|
label = t("dropZone.overlayDefault"),
|
||||||
children
|
children,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let depth = $state(0);
|
let depth = $state(0);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ACCEPTED_IMAGE_TYPES, isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
import {
|
||||||
import { t } from '$lib/i18n/t';
|
ACCEPTED_IMAGE_TYPES,
|
||||||
|
isSupportedImage,
|
||||||
|
unsupportedImageError,
|
||||||
|
} from "$lib/core/io";
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFile: (file: File) => void;
|
onFile: (file: File) => void;
|
||||||
@@ -8,7 +12,7 @@
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onFile, onError, label = t('dropZone.pickDefault') }: Props = $props();
|
let { onFile, onError, label = t("dropZone.pickDefault") }: Props = $props();
|
||||||
|
|
||||||
let input = $state<HTMLInputElement | undefined>();
|
let input = $state<HTMLInputElement | undefined>();
|
||||||
let depth = $state(0);
|
let depth = $state(0);
|
||||||
@@ -52,7 +56,7 @@
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
onclick={openPicker}
|
onclick={openPicker}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openPicker();
|
openPicker();
|
||||||
}
|
}
|
||||||
@@ -78,7 +82,7 @@
|
|||||||
onchange={() => {
|
onchange={() => {
|
||||||
accept(input?.files?.[0]);
|
accept(input?.files?.[0]);
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = '';
|
input.value = "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ImageInfo } from '$lib/core/analyze';
|
import type { ImageInfo } from "$lib/core/analyze";
|
||||||
import { LOCALE_TAGS } from '$lib/i18n/dict';
|
import { LOCALE_TAGS } from "$lib/i18n/dict";
|
||||||
import { getLocale } from '$lib/i18n/locale.svelte';
|
import { getLocale } from "$lib/i18n/locale.svelte";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
info: ImageInfo | null;
|
info: ImageInfo | null;
|
||||||
@@ -14,15 +14,17 @@
|
|||||||
{#if info}
|
{#if info}
|
||||||
<dl>
|
<dl>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<dt>{t('infoPanel.dimensions')}</dt>
|
<dt>{t("infoPanel.dimensions")}</dt>
|
||||||
<dd>{info.width} × {info.height} px</dd>
|
<dd>{info.width} × {info.height} px</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<dt>{t('infoPanel.alpha')}</dt>
|
<dt>{t("infoPanel.alpha")}</dt>
|
||||||
<dd>{info.hasAlpha ? t('infoPanel.alphaYes') : t('infoPanel.alphaNo')}</dd>
|
<dd>
|
||||||
|
{info.hasAlpha ? t("infoPanel.alphaYes") : t("infoPanel.alphaNo")}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<dt>{t('infoPanel.colorCount')}</dt>
|
<dt>{t("infoPanel.colorCount")}</dt>
|
||||||
<dd>{info.colorCount.toLocaleString(LOCALE_TAGS[getLocale()])}</dd>
|
<dd>{info.colorCount.toLocaleString(LOCALE_TAGS[getLocale()])}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CheckboxField from './ui/CheckboxField.svelte';
|
import CheckboxField from "./ui/CheckboxField.svelte";
|
||||||
import ColorField from './ui/ColorField.svelte';
|
import ColorField from "./ui/ColorField.svelte";
|
||||||
import SelectField from './ui/SelectField.svelte';
|
import SelectField from "./ui/SelectField.svelte";
|
||||||
import SliderField from './ui/SliderField.svelte';
|
import SliderField from "./ui/SliderField.svelte";
|
||||||
import TextField from './ui/TextField.svelte';
|
import TextField from "./ui/TextField.svelte";
|
||||||
import type { ParamDef, ToolEntry } from '$lib/registry';
|
import type { ParamDef, ToolEntry } from "$lib/registry";
|
||||||
import { optionLabel, paramLabel } from '$lib/i18n/tool-strings';
|
import { optionLabel, paramLabel } from "$lib/i18n/tool-strings";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
@@ -25,19 +25,27 @@
|
|||||||
pipetteTargetId = null,
|
pipetteTargetId = null,
|
||||||
onPipetteToggle,
|
onPipetteToggle,
|
||||||
hasMask = false,
|
hasMask = false,
|
||||||
showMask = $bindable(false)
|
showMask = $bindable(false),
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="params-grid">
|
<div class="params-grid">
|
||||||
{#if hasMask}
|
{#if hasMask}
|
||||||
<CheckboxField id="show-mask" label={t('ui.showMask')} bind:checked={showMask} />
|
<CheckboxField
|
||||||
|
id="show-mask"
|
||||||
|
label={t("ui.showMask")}
|
||||||
|
bind:checked={showMask}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#each params as param (param.id)}
|
{#each params as param (param.id)}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{#if param.type === 'checkbox'}
|
{#if param.type === "checkbox"}
|
||||||
<CheckboxField id={param.id} label={paramLabel(tool, param)} bind:checked={values[param.id]} />
|
<CheckboxField
|
||||||
{:else if param.type === 'number'}
|
id={param.id}
|
||||||
|
label={paramLabel(tool, param)}
|
||||||
|
bind:checked={values[param.id]}
|
||||||
|
/>
|
||||||
|
{:else if param.type === "number"}
|
||||||
<TextField
|
<TextField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={paramLabel(tool, param)}
|
label={paramLabel(tool, param)}
|
||||||
@@ -47,7 +55,7 @@
|
|||||||
step={param.step}
|
step={param.step}
|
||||||
bind:value={values[param.id]}
|
bind:value={values[param.id]}
|
||||||
/>
|
/>
|
||||||
{:else if param.type === 'slider'}
|
{:else if param.type === "slider"}
|
||||||
<SliderField
|
<SliderField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={paramLabel(tool, param)}
|
label={paramLabel(tool, param)}
|
||||||
@@ -57,17 +65,17 @@
|
|||||||
default={param.default}
|
default={param.default}
|
||||||
bind:value={values[param.id]}
|
bind:value={values[param.id]}
|
||||||
/>
|
/>
|
||||||
{:else if param.type === 'select'}
|
{:else if param.type === "select"}
|
||||||
<SelectField
|
<SelectField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={paramLabel(tool, param)}
|
label={paramLabel(tool, param)}
|
||||||
options={param.options.map((o) => ({
|
options={param.options.map((o) => ({
|
||||||
value: o.value,
|
value: o.value,
|
||||||
label: optionLabel(tool, param, o.value)
|
label: optionLabel(tool, param, o.value),
|
||||||
}))}
|
}))}
|
||||||
bind:value={values[param.id]}
|
bind:value={values[param.id]}
|
||||||
/>
|
/>
|
||||||
{:else if param.type === 'color'}
|
{:else if param.type === "color"}
|
||||||
<ColorField
|
<ColorField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={paramLabel(tool, param)}
|
label={paramLabel(tool, param)}
|
||||||
@@ -75,7 +83,7 @@
|
|||||||
pipetteActive={pipetteTargetId === param.id}
|
pipetteActive={pipetteTargetId === param.id}
|
||||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
||||||
/>
|
/>
|
||||||
{:else if param.type === 'text'}
|
{:else if param.type === "text"}
|
||||||
<TextField
|
<TextField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
label={paramLabel(tool, param)}
|
label={paramLabel(tool, param)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { rgbToHex } from '$lib/core/color';
|
import { rgbToHex } from "$lib/core/color";
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import PipetteLoupe from './tool/PipetteLoupe.svelte';
|
import PipetteLoupe from "./tool/PipetteLoupe.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
image: PixelImage | null;
|
image: PixelImage | null;
|
||||||
@@ -12,27 +12,42 @@
|
|||||||
let { image, pipetteActive = false, onPickColor }: Props = $props();
|
let { image, pipetteActive = false, onPickColor }: Props = $props();
|
||||||
|
|
||||||
let canvas = $state<HTMLCanvasElement | undefined>();
|
let canvas = $state<HTMLCanvasElement | undefined>();
|
||||||
let hover = $state<{ clientX: number; clientY: number; px: number; py: number; hex: string } | null>(
|
let hover = $state<{
|
||||||
null
|
clientX: number;
|
||||||
);
|
clientY: number;
|
||||||
|
px: number;
|
||||||
|
py: number;
|
||||||
|
hex: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!canvas || !image) return;
|
if (!canvas || !image) return;
|
||||||
canvas.width = image.width;
|
canvas.width = image.width;
|
||||||
canvas.height = image.height;
|
canvas.height = image.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
|
ctx.putImageData(
|
||||||
|
new ImageData(image.data, image.width, image.height),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function pixelAt(event: MouseEvent): { px: number; py: number; hex: string } | null {
|
function pixelAt(
|
||||||
|
event: MouseEvent,
|
||||||
|
): { px: number; py: number; hex: string } | null {
|
||||||
if (!canvas || !image) return null;
|
if (!canvas || !image) return null;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
if (rect.width === 0 || rect.height === 0) return null;
|
if (rect.width === 0 || rect.height === 0) return null;
|
||||||
const px = Math.floor((event.clientX - rect.left) * (canvas.width / rect.width));
|
const px = Math.floor(
|
||||||
const py = Math.floor((event.clientY - rect.top) * (canvas.height / rect.height));
|
(event.clientX - rect.left) * (canvas.width / rect.width),
|
||||||
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null;
|
);
|
||||||
const ctx = canvas.getContext('2d');
|
const py = Math.floor(
|
||||||
|
(event.clientY - rect.top) * (canvas.height / rect.height),
|
||||||
|
);
|
||||||
|
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height)
|
||||||
|
return null;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
|
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
|
||||||
return { px, py, hex: rgbToHex(r, g, b) };
|
return { px, py, hex: rgbToHex(r, g, b) };
|
||||||
@@ -53,7 +68,13 @@
|
|||||||
}
|
}
|
||||||
const pixel = pixelAt(event);
|
const pixel = pixelAt(event);
|
||||||
hover = pixel
|
hover = pixel
|
||||||
? { clientX: event.clientX, clientY: event.clientY, px: pixel.px, py: pixel.py, hex: pixel.hex }
|
? {
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
px: pixel.px,
|
||||||
|
py: pixel.py,
|
||||||
|
hex: pixel.hex,
|
||||||
|
}
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +111,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 32rem;
|
max-height: 32rem;
|
||||||
background:
|
background: repeating-conic-gradient(
|
||||||
repeating-conic-gradient(var(--check-a) 0% 25%, var(--check-b) 0% 50%);
|
var(--check-a) 0% 25%,
|
||||||
|
var(--check-b) 0% 50%
|
||||||
|
);
|
||||||
background-size: 16px 16px;
|
background-size: 16px 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,81 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
import { imageInfo, type ImageInfo } from "$lib/core/analyze";
|
||||||
import { ToolError } from '$lib/core/errors';
|
import { ToolError } from "$lib/core/errors";
|
||||||
import { decodeFile, isSupportedImage, unsupportedImageError } from '$lib/core/io';
|
import {
|
||||||
import type { PixelImage } from '$lib/core/types';
|
decodeFile,
|
||||||
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
isSupportedImage,
|
||||||
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
|
unsupportedImageError,
|
||||||
import { TOOL_ICONS } from '$lib/tools/tool-icons';
|
} from "$lib/core/io";
|
||||||
import DownloadButton from './DownloadButton.svelte';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import ParamForm from './ParamForm.svelte';
|
import {
|
||||||
import Preview from './Preview.svelte';
|
defaultParams,
|
||||||
import ToolSearch from './search/ToolSearch.svelte';
|
getTool,
|
||||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
outputOf,
|
||||||
import { executeStep } from '$lib/tools/executor';
|
sanitizeParams,
|
||||||
import { clearOverlay, setOverlay } from '$lib/tools/overlay-store.svelte';
|
type ToolEntry,
|
||||||
import { t } from '$lib/i18n/t';
|
} from "$lib/registry";
|
||||||
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
|
import {
|
||||||
import type { StageStatus } from './stage/stage-props';
|
loadStoredSteps,
|
||||||
import ChainToolBlock from './chain/ChainToolBlock.svelte';
|
newStepId,
|
||||||
import ToolStage from './stage/ToolStage.svelte';
|
saveSteps,
|
||||||
import ToolStageClassic from './stage/ToolStageClassic.svelte';
|
type PipelineStep,
|
||||||
|
} from "$lib/tools/pipeline";
|
||||||
|
import { TOOL_ICONS } from "$lib/tools/tool-icons";
|
||||||
|
import DownloadButton from "./DownloadButton.svelte";
|
||||||
|
import ParamForm from "./ParamForm.svelte";
|
||||||
|
import Preview from "./Preview.svelte";
|
||||||
|
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";
|
||||||
|
import ChainToolBlock from "./chain/ChainToolBlock.svelte";
|
||||||
|
import ToolStage from "./stage/ToolStage.svelte";
|
||||||
|
import ToolStageClassic from "./stage/ToolStageClassic.svelte";
|
||||||
|
|
||||||
export type PresetStep = { toolId: string; values?: Record<string, unknown> };
|
export type PresetStep = { toolId: string; values?: Record<string, unknown> };
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tool,
|
tool,
|
||||||
restoreChain = false,
|
restoreChain = false,
|
||||||
stageVariant = 'inline',
|
stageVariant = "inline",
|
||||||
presetBaseValues,
|
presetBaseValues,
|
||||||
presetChain
|
presetChain,
|
||||||
}: {
|
}: {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
restoreChain?: boolean;
|
restoreChain?: boolean;
|
||||||
stageVariant?: 'classic' | 'inline';
|
stageVariant?: "classic" | "inline";
|
||||||
presetBaseValues?: Record<string, unknown>;
|
presetBaseValues?: Record<string, unknown>;
|
||||||
presetChain?: PresetStep[];
|
presetChain?: PresetStep[];
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const StageComponent = $derived(stageVariant === 'classic' ? ToolStageClassic : ToolStage);
|
const StageComponent = $derived(
|
||||||
|
stageVariant === "classic" ? ToolStageClassic : ToolStage,
|
||||||
|
);
|
||||||
|
|
||||||
type Status = StageStatus;
|
type Status = StageStatus;
|
||||||
|
|
||||||
const isPreset = $derived(
|
const isPreset = $derived(
|
||||||
(presetChain?.length ?? 0) > 0 || Object.keys(presetBaseValues ?? {}).length > 0
|
(presetChain?.length ?? 0) > 0 ||
|
||||||
|
Object.keys(presetBaseValues ?? {}).length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = $state<Status>('idle');
|
let status = $state<Status>("idle");
|
||||||
let source = $state<PixelImage | null>(null);
|
let source = $state<PixelImage | null>(null);
|
||||||
let result = $state<PixelImage | null>(null);
|
let result = $state<PixelImage | null>(null);
|
||||||
let previewResult = $state<PixelImage | null>(null);
|
let previewResult = $state<PixelImage | null>(null);
|
||||||
let textResult = $state<string | null>(null);
|
let textResult = $state<string | null>(null);
|
||||||
let showMask = $state(false);
|
let showMask = $state(false);
|
||||||
let info = $state<ImageInfo | null>(null);
|
let info = $state<ImageInfo | null>(null);
|
||||||
let errorText = $state('');
|
let errorText = $state("");
|
||||||
let overlayImage = $state<PixelImage | null>(null);
|
let overlayImage = $state<PixelImage | null>(null);
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let values = $state<Record<string, any>>({ ...defaultParams(tool), ...presetBaseValues });
|
let values = $state<Record<string, any>>({
|
||||||
|
...defaultParams(tool),
|
||||||
|
...presetBaseValues,
|
||||||
|
});
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let chain = $state<PipelineStep[]>(
|
let chain = $state<PipelineStep[]>(
|
||||||
restoreChain
|
restoreChain
|
||||||
@@ -66,28 +87,30 @@
|
|||||||
{
|
{
|
||||||
id: newStepId(),
|
id: newStepId(),
|
||||||
toolId: preset.toolId,
|
toolId: preset.toolId,
|
||||||
values: { ...defaultParams(stepTool), ...preset.values }
|
values: { ...defaultParams(stepTool), ...preset.values },
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
let chainResults = $state<(PixelImage | null)[]>([]);
|
let chainResults = $state<(PixelImage | null)[]>([]);
|
||||||
let lastRunChainJson = '';
|
let lastRunChainJson = "";
|
||||||
|
|
||||||
const isInfo = $derived(tool.resultType === 'info');
|
const isInfo = $derived(tool.resultType === "info");
|
||||||
const isSourceless = $derived(tool.sourceMode === 'none');
|
const isSourceless = $derived(tool.sourceMode === "none");
|
||||||
const isTextSource = $derived(tool.sourceMode === 'text');
|
const isTextSource = $derived(tool.sourceMode === "text");
|
||||||
const sanitized = $derived(sanitizeParams(tool, values));
|
const sanitized = $derived(sanitizeParams(tool, values));
|
||||||
const canChainBase = $derived(
|
const canChainBase = $derived(
|
||||||
(tool.resultType ?? 'image') === 'image' && tool.sourceMode !== 'text'
|
(tool.resultType ?? "image") === "image" && tool.sourceMode !== "text",
|
||||||
);
|
);
|
||||||
const hasMask = $derived(typeof tool.preview === 'function');
|
const hasMask = $derived(typeof tool.preview === "function");
|
||||||
const shownBase = $derived(showMask && previewResult ? previewResult : result);
|
const shownBase = $derived(
|
||||||
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ''));
|
showMask && previewResult ? previewResult : result,
|
||||||
|
);
|
||||||
|
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ""));
|
||||||
|
|
||||||
function addChainStep() {
|
function addChainStep() {
|
||||||
chain.push({ id: newStepId(), toolId: '', values: {} });
|
chain.push({ id: newStepId(), toolId: "", values: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeChainStep(index: number) {
|
function removeChainStep(index: number) {
|
||||||
@@ -118,7 +141,7 @@
|
|||||||
const runner = createAutoRunner();
|
const runner = createAutoRunner();
|
||||||
let hasLastRun = false;
|
let hasLastRun = false;
|
||||||
let lastRunSource: PixelImage | null = null;
|
let lastRunSource: PixelImage | null = null;
|
||||||
let lastRunValuesJson = '';
|
let lastRunValuesJson = "";
|
||||||
let pipetteTargetId = $state<string | null>(null);
|
let pipetteTargetId = $state<string | null>(null);
|
||||||
|
|
||||||
function handlePipetteToggle(id: string) {
|
function handlePipetteToggle(id: string) {
|
||||||
@@ -132,8 +155,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
errorText = '';
|
errorText = "";
|
||||||
status = 'processing';
|
status = "processing";
|
||||||
try {
|
try {
|
||||||
source = await decodeFile(file);
|
source = await decodeFile(file);
|
||||||
values = defaultParams(tool);
|
values = defaultParams(tool);
|
||||||
@@ -143,7 +166,7 @@
|
|||||||
pipetteTargetId = null;
|
pipetteTargetId = null;
|
||||||
info = isInfo ? imageInfo(source) : null;
|
info = isInfo ? imageInfo(source) : null;
|
||||||
if (isInfo) {
|
if (isInfo) {
|
||||||
status = 'loaded';
|
status = "loaded";
|
||||||
} else {
|
} else {
|
||||||
await runTool();
|
await runTool();
|
||||||
}
|
}
|
||||||
@@ -154,15 +177,15 @@
|
|||||||
|
|
||||||
async function handleTextSubmit(text: string) {
|
async function handleTextSubmit(text: string) {
|
||||||
if (!isTextSource) return;
|
if (!isTextSource) return;
|
||||||
errorText = '';
|
errorText = "";
|
||||||
status = 'processing';
|
status = "processing";
|
||||||
if (tool.textToText) {
|
if (tool.textToText) {
|
||||||
try {
|
try {
|
||||||
result = null;
|
result = null;
|
||||||
previewResult = null;
|
previewResult = null;
|
||||||
info = null;
|
info = null;
|
||||||
textResult = await tool.textToText(text);
|
textResult = await tool.textToText(text);
|
||||||
status = 'loaded';
|
status = "loaded";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(e);
|
showError(e);
|
||||||
}
|
}
|
||||||
@@ -196,7 +219,7 @@
|
|||||||
let next: PixelImage | null = null;
|
let next: PixelImage | null = null;
|
||||||
let nextText: string | null = null;
|
let nextText: string | null = null;
|
||||||
|
|
||||||
if (tool.resultType === 'text') {
|
if (tool.resultType === "text") {
|
||||||
nextText = await tool.toText!(source!, sanitized);
|
nextText = await tool.toText!(source!, sanitized);
|
||||||
} else if (isSourceless) {
|
} else if (isSourceless) {
|
||||||
next = await tool.generate!(sanitized);
|
next = await tool.generate!(sanitized);
|
||||||
@@ -221,23 +244,31 @@
|
|||||||
let current: PixelImage | null = next ?? source;
|
let current: PixelImage | null = next ?? source;
|
||||||
for (let i = 0; i < chain.length; i++) {
|
for (let i = 0; i < chain.length; i++) {
|
||||||
const step = chain[i];
|
const step = chain[i];
|
||||||
const stepTool = step.toolId === '' ? undefined : getTool(step.toolId);
|
const stepTool = step.toolId === "" ? undefined : getTool(step.toolId);
|
||||||
if (!current || !stepTool?.run) {
|
if (!current || !stepTool?.run) {
|
||||||
collected.push(null);
|
collected.push(null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
|
current = await executeStep(
|
||||||
|
stepTool,
|
||||||
|
current,
|
||||||
|
sanitizeParams(stepTool, step.values),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t('toolPage.stepError', { n: i + 2, title: toolTitle(stepTool), msg: errorMessage(e) })
|
t("toolPage.stepError", {
|
||||||
|
n: i + 2,
|
||||||
|
title: toolTitle(stepTool),
|
||||||
|
msg: errorMessage(e),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!runner.isCurrent(token)) return;
|
if (!runner.isCurrent(token)) return;
|
||||||
collected.push(current);
|
collected.push(current);
|
||||||
}
|
}
|
||||||
chainResults = collected;
|
chainResults = collected;
|
||||||
status = 'loaded';
|
status = "loaded";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!runner.isCurrent(token)) return;
|
if (!runner.isCurrent(token)) return;
|
||||||
showError(e);
|
showError(e);
|
||||||
@@ -262,7 +293,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isPreset) return;
|
if (isPreset) return;
|
||||||
const filled = chain.filter((step) => step.toolId !== '');
|
const filled = chain.filter((step) => step.toolId !== "");
|
||||||
if (filled.length === 0 && !hasLastRun) return;
|
if (filled.length === 0 && !hasLastRun) return;
|
||||||
saveSteps(filled);
|
saveSteps(filled);
|
||||||
});
|
});
|
||||||
@@ -273,7 +304,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showError(e: unknown) {
|
function showError(e: unknown) {
|
||||||
status = source ? 'loaded' : 'idle';
|
status = source ? "loaded" : "idle";
|
||||||
errorText = errorMessage(e);
|
errorText = errorMessage(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,8 +316,8 @@
|
|||||||
showMask = false;
|
showMask = false;
|
||||||
pipetteTargetId = null;
|
pipetteTargetId = null;
|
||||||
info = null;
|
info = null;
|
||||||
errorText = '';
|
errorText = "";
|
||||||
status = 'idle';
|
status = "idle";
|
||||||
clearOverlay();
|
clearOverlay();
|
||||||
values = { ...defaultParams(tool), ...presetBaseValues };
|
values = { ...defaultParams(tool), ...presetBaseValues };
|
||||||
}
|
}
|
||||||
@@ -303,7 +334,7 @@
|
|||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!item.type.startsWith('image/')) continue;
|
if (!item.type.startsWith("image/")) continue;
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) {
|
if (file) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -331,32 +362,32 @@
|
|||||||
<StageComponent
|
<StageComponent
|
||||||
mode="base"
|
mode="base"
|
||||||
{tool}
|
{tool}
|
||||||
source={source}
|
{source}
|
||||||
result={result}
|
{result}
|
||||||
displayImage={shownBase}
|
displayImage={shownBase}
|
||||||
info={info}
|
{info}
|
||||||
status={status}
|
{status}
|
||||||
isInfo={isInfo}
|
{isInfo}
|
||||||
isSourceless={isSourceless}
|
{isSourceless}
|
||||||
isTextSource={isTextSource}
|
{isTextSource}
|
||||||
textResult={textResult}
|
{textResult}
|
||||||
sanitized={sanitized}
|
{sanitized}
|
||||||
canChainBase={canChainBase}
|
{canChainBase}
|
||||||
hasChain={chain.length > 0}
|
hasChain={chain.length > 0}
|
||||||
hasMask={hasMask}
|
{hasMask}
|
||||||
bind:showMask
|
bind:showMask
|
||||||
bind:values
|
bind:values
|
||||||
pipetteTargetId={pipetteTargetId}
|
{pipetteTargetId}
|
||||||
handleFile={handleFile}
|
{handleFile}
|
||||||
onTextSubmit={handleTextSubmit}
|
onTextSubmit={handleTextSubmit}
|
||||||
onSourceError={(e) => (errorText = errorMessage(e))}
|
onSourceError={(e) => (errorText = errorMessage(e))}
|
||||||
reset={reset}
|
{reset}
|
||||||
toggleChain={toggleChain}
|
{toggleChain}
|
||||||
handlePipetteToggle={handlePipetteToggle}
|
{handlePipetteToggle}
|
||||||
handlePickColor={handlePickColor}
|
{handlePickColor}
|
||||||
showError={showError}
|
{showError}
|
||||||
errorMessage={errorMessage}
|
{errorMessage}
|
||||||
overlayImage={overlayImage}
|
{overlayImage}
|
||||||
onOverlayFile={(f) => void handleOverlayFile(f)}
|
onOverlayFile={(f) => void handleOverlayFile(f)}
|
||||||
onOverlayError={(e) => (errorText = errorMessage(e))}
|
onOverlayError={(e) => (errorText = errorMessage(e))}
|
||||||
onOverlayClear={clearOverlay}
|
onOverlayClear={clearOverlay}
|
||||||
@@ -364,33 +395,38 @@
|
|||||||
|
|
||||||
<div class="chain-stack">
|
<div class="chain-stack">
|
||||||
{#each chain as step, index (step.id)}
|
{#each chain as step, index (step.id)}
|
||||||
{#if step.toolId === ''}
|
{#if step.toolId === ""}
|
||||||
<div class="panel empty-slot">
|
<div class="panel empty-slot">
|
||||||
<header>
|
<header>
|
||||||
<h3 class="heading-section">{t('toolPage.stepHeading', { n: index + 2 })}</h3>
|
<h3 class="heading-section">
|
||||||
<button
|
{t("toolPage.stepHeading", { n: index + 2 })}
|
||||||
type="button"
|
</h3>
|
||||||
class="remove-step"
|
<button
|
||||||
aria-label={t('toolPage.removeStepAria')}
|
type="button"
|
||||||
onclick={() => removeChainStep(index)}
|
class="remove-step"
|
||||||
>
|
aria-label={t("toolPage.removeStepAria")}
|
||||||
✕
|
onclick={() => removeChainStep(index)}
|
||||||
</button>
|
>
|
||||||
</header>
|
✕
|
||||||
<ToolSearch onSelect={(id) => applyChainTool(index, id)} chainableOnly />
|
</button>
|
||||||
|
</header>
|
||||||
|
<ToolSearch
|
||||||
|
onSelect={(id) => applyChainTool(index, id)}
|
||||||
|
chainableOnly
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if getTool(step.toolId)}
|
{:else if getTool(step.toolId)}
|
||||||
{@const stepTool = getTool(step.toolId)!}
|
{@const stepTool = getTool(step.toolId)!}
|
||||||
{#if stageVariant === 'inline'}
|
{#if stageVariant === "inline"}
|
||||||
{@const StepIcon = TOOL_ICONS[stepTool.id]}
|
{@const StepIcon = TOOL_ICONS[stepTool.id]}
|
||||||
<ToolStage
|
<ToolStage
|
||||||
mode="chain"
|
mode="chain"
|
||||||
index={index}
|
{index}
|
||||||
tool={stepTool}
|
tool={stepTool}
|
||||||
bind:values={step.values}
|
bind:values={step.values}
|
||||||
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
||||||
result={chainResults[index] ?? null}
|
result={chainResults[index] ?? null}
|
||||||
busy={status === 'processing'}
|
busy={status === "processing"}
|
||||||
isLast={index === chain.length - 1}
|
isLast={index === chain.length - 1}
|
||||||
onRemove={() => removeChainStep(index)}
|
onRemove={() => removeChainStep(index)}
|
||||||
onError={showError}
|
onError={showError}
|
||||||
@@ -404,12 +440,15 @@
|
|||||||
<StepIcon size={14} strokeWidth={2} />
|
<StepIcon size={14} strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{t('chain.stepLabel', { n: index + 2, title: toolTitle(stepTool) })}
|
{t("chain.stepLabel", {
|
||||||
|
n: index + 2,
|
||||||
|
title: toolTitle(stepTool),
|
||||||
|
})}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="remove-step"
|
class="remove-step"
|
||||||
aria-label={t('toolPage.removeStepAria')}
|
aria-label={t("toolPage.removeStepAria")}
|
||||||
title={t('toolPage.removeStepAria')}
|
title={t("toolPage.removeStepAria")}
|
||||||
onclick={() => removeChainStep(index)}
|
onclick={() => removeChainStep(index)}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -419,12 +458,12 @@
|
|||||||
</ToolStage>
|
</ToolStage>
|
||||||
{:else}
|
{:else}
|
||||||
<ChainToolBlock
|
<ChainToolBlock
|
||||||
index={index}
|
{index}
|
||||||
tool={stepTool}
|
tool={stepTool}
|
||||||
bind:values={step.values}
|
bind:values={step.values}
|
||||||
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
||||||
result={chainResults[index] ?? null}
|
result={chainResults[index] ?? null}
|
||||||
busy={status === 'processing'}
|
busy={status === "processing"}
|
||||||
isLast={index === chain.length - 1}
|
isLast={index === chain.length - 1}
|
||||||
onRemove={() => removeChainStep(index)}
|
onRemove={() => removeChainStep(index)}
|
||||||
onError={showError}
|
onError={showError}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/registry";
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
import { toolTitle } from '$lib/i18n/tool-strings';
|
import { toolTitle } from "$lib/i18n/tool-strings";
|
||||||
import DownloadButton from '../DownloadButton.svelte';
|
import DownloadButton from "../DownloadButton.svelte";
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from "../ui/Button.svelte";
|
||||||
import EmptyState from '../ui/EmptyState.svelte';
|
import EmptyState from "../ui/EmptyState.svelte";
|
||||||
import ParamsCard from '../tool/ParamsCard.svelte';
|
import ParamsCard from "../tool/ParamsCard.svelte";
|
||||||
import Preview from '../Preview.svelte';
|
import Preview from "../Preview.svelte";
|
||||||
import { TOOL_ICONS } from '$lib/tools/tool-icons';
|
import { TOOL_ICONS } from "$lib/tools/tool-icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
onRemove,
|
onRemove,
|
||||||
onError,
|
onError,
|
||||||
onAddStep,
|
onAddStep,
|
||||||
onRemoveChain
|
onRemoveChain,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const format = $derived(outputOf(tool));
|
const format = $derived(outputOf(tool));
|
||||||
@@ -47,14 +47,16 @@
|
|||||||
<div class="panel tool-stage">
|
<div class="panel tool-stage">
|
||||||
<span class="edge-legend step-legend">
|
<span class="edge-legend step-legend">
|
||||||
{#if StepIcon}
|
{#if StepIcon}
|
||||||
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
|
<span class="step-icon" aria-hidden="true"
|
||||||
|
><StepIcon size={14} strokeWidth={2} /></span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{t('chain.stepLabel', { n: index + 2, title: toolTitle(tool) })}
|
{t("chain.stepLabel", { n: index + 2, title: toolTitle(tool) })}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="remove"
|
class="remove"
|
||||||
aria-label={t('chain.removeStepAria')}
|
aria-label={t("chain.removeStepAria")}
|
||||||
title={t('chain.removeStepAria')}
|
title={t("chain.removeStepAria")}
|
||||||
onclick={onRemove}
|
onclick={onRemove}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -62,34 +64,44 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<span class="edge-legend cell-legend" aria-hidden="true">{t('chain.inputLegend')}</span>
|
<span class="edge-legend cell-legend" aria-hidden="true"
|
||||||
|
>{t("chain.inputLegend")}</span
|
||||||
|
>
|
||||||
<div class="cell-media">
|
<div class="cell-media">
|
||||||
<Preview image={input} />
|
<Preview image={input} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<span class="edge-legend cell-legend" aria-hidden="true">{t('chain.resultLegend')}</span>
|
<span class="edge-legend cell-legend" aria-hidden="true"
|
||||||
|
>{t("chain.resultLegend")}</span
|
||||||
|
>
|
||||||
{#if busy && !result}
|
{#if busy && !result}
|
||||||
<div class="cell-media">
|
<div class="cell-media">
|
||||||
<EmptyState title={t('chain.busyTitle')} hint={t('chain.busyHint')} />
|
<EmptyState title={t("chain.busyTitle")} hint={t("chain.busyHint")} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="cell-media">
|
<div class="cell-media">
|
||||||
<Preview image={result} />
|
<Preview image={result} />
|
||||||
{#if busy}
|
{#if busy}
|
||||||
<span class="recalc" aria-live="polite">{t('resultCard.recalc')}</span>
|
<span class="recalc" aria-live="polite"
|
||||||
|
>{t("resultCard.recalc")}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
image={result}
|
image={result}
|
||||||
format={format}
|
{format}
|
||||||
baseName="{index + 2}-{tool.id}"
|
baseName="{index + 2}-{tool.id}"
|
||||||
params={safeParams}
|
params={safeParams}
|
||||||
{onError}
|
{onError}
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
|
<Button
|
||||||
{isLast ? t('resultCard.nextTool') : t('resultCard.breakChain')}
|
variant="secondary"
|
||||||
|
fullWidth
|
||||||
|
onclick={isLast ? onAddStep : onRemoveChain}
|
||||||
|
>
|
||||||
|
{isLast ? t("resultCard.nextTool") : t("resultCard.breakChain")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -98,9 +110,11 @@
|
|||||||
|
|
||||||
{#if tool.params.length > 0}
|
{#if tool.params.length > 0}
|
||||||
<div class="params-sep">
|
<div class="params-sep">
|
||||||
<span class="edge-legend" aria-hidden="true">{t('chain.paramsLegend')}</span>
|
<span class="edge-legend" aria-hidden="true"
|
||||||
|
>{t("chain.paramsLegend")}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<ParamsCard tool={tool} params={tool.params} bind:values />
|
<ParamsCard {tool} params={tool.params} bind:values />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Upload } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accept?: string;
|
||||||
|
onfile?: (file: File) => void;
|
||||||
|
}
|
||||||
|
let { accept = "image/*", onfile }: Props = $props();
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
function handleFiles(files: FileList | null | undefined) {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (file) onfile?.(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
handleFiles(e.dataTransfer?.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
input?.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="dropzone"
|
||||||
|
class:dragging
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (dragging = false)}
|
||||||
|
ondrop={onDrop}
|
||||||
|
onclick={open}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
type="file"
|
||||||
|
{accept}
|
||||||
|
hidden
|
||||||
|
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||||
|
/>
|
||||||
|
<Upload size={22} />
|
||||||
|
<span class="dz-title">Drop a PNG here</span>
|
||||||
|
<span class="dz-sub">or click to browse — processed locally</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--background);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
}
|
||||||
|
.dropzone:hover,
|
||||||
|
.dropzone:focus-visible,
|
||||||
|
.dropzone.dragging {
|
||||||
|
border-color: var(--blue);
|
||||||
|
color: var(--foreground);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.dz-title {
|
||||||
|
font: 600 0.9rem var(--font-mono);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.dz-sub {
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,10 +6,11 @@
|
|||||||
icon: Component<{ size?: number; class?: string }>;
|
icon: Component<{ size?: number; class?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
variant?: "ghost" | "solid" | "accent";
|
variant?: "ghost" | "solid" | "accent" | "bare";
|
||||||
size?: number;
|
size?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
@@ -57,6 +58,18 @@
|
|||||||
color: #042;
|
color: #042;
|
||||||
border-color: var(--cyan);
|
border-color: var(--cyan);
|
||||||
}
|
}
|
||||||
|
.icon-btn--bare {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.icon-btn--bare:hover:not(:disabled) {
|
||||||
|
color: var(--foreground);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
.icon-btn:disabled {
|
.icon-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Field from "./Field.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
oninput?: (value: number) => void;
|
||||||
|
}
|
||||||
|
let { label, value = $bindable(), min, max, step, oninput }: Props = $props();
|
||||||
|
|
||||||
|
function handle(e: Event) {
|
||||||
|
value = Number((e.target as HTMLInputElement).value);
|
||||||
|
oninput?.(value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Field {label}>
|
||||||
|
<input
|
||||||
|
class="number-field"
|
||||||
|
type="number"
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
{value}
|
||||||
|
oninput={handle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.number-field {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
.number-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--blue);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import MonoLabel from "./MonoLabel.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,7 +9,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="preview-tile">
|
<div class="preview-tile">
|
||||||
<MonoLabel>{label}</MonoLabel>
|
<span class="tile-label">{label}</span>
|
||||||
<div class="tile-canvas">{@render children()}</div>
|
<div class="tile-canvas">{@render children()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,6 +23,12 @@
|
|||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
.tile-label {
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
.tile-canvas {
|
.tile-canvas {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.segmented {
|
.segmented {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
height: 32px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -44,11 +45,13 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0 0.7rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
.segment + .segment {
|
.segment + .segment {
|
||||||
border-left: 1px solid var(--line);
|
border-left: 1px solid var(--line);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { GripVertical, X } from "@lucide/svelte";
|
import { GripVertical, X } from "@lucide/svelte";
|
||||||
import Badge from "./Badge.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -17,7 +16,7 @@
|
|||||||
<div class="step-heading">
|
<div class="step-heading">
|
||||||
<span class="drag" aria-hidden="true"><GripVertical size={16} /></span>
|
<span class="drag" aria-hidden="true"><GripVertical size={16} /></span>
|
||||||
<span class="step-index">{index.toString().padStart(2, "0")}</span>
|
<span class="step-index">{index.toString().padStart(2, "0")}</span>
|
||||||
{#if type}<Badge tone="accent">{type}</Badge>{/if}
|
{#if type}<span class="step-type">{type}</span>{/if}
|
||||||
{#if title}<span class="step-title">{title}</span>{/if}
|
{#if title}<span class="step-title">{title}</span>{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -54,13 +53,18 @@
|
|||||||
.step-index {
|
.step-index {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--muted);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
.step-title {
|
.step-title {
|
||||||
font-weight: 600;
|
font: 600 15px var(--font-mono);
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
flex: 1;
|
margin: 0;
|
||||||
|
}
|
||||||
|
.step-type {
|
||||||
|
color: var(--muted);
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.step-remove {
|
.step-remove {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import IconButton from "./IconButton.svelte";
|
import IconButton from "./IconButton.svelte";
|
||||||
import Segmented from "./Segmented.svelte";
|
|
||||||
import StatusDot from "./StatusDot.svelte";
|
import StatusDot from "./StatusDot.svelte";
|
||||||
import { CircleHelp, Moon, Sun } from "@lucide/svelte";
|
import { CircleHelp, Moon, Sun } from "@lucide/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
|
crumb?: string;
|
||||||
ontoggle: () => void;
|
ontoggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { theme, ontoggle }: Props = $props();
|
let { theme, crumb, ontoggle }: Props = $props();
|
||||||
|
|
||||||
let lang = $state("RU");
|
let lang = $state("RU");
|
||||||
</script>
|
</script>
|
||||||
@@ -18,22 +18,36 @@
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark">EP</span>
|
<span class="brand-mark">EP</span>
|
||||||
<span>easy-png-tools</span>
|
<span>easy-png-tools</span>
|
||||||
|
{#if crumb}<span class="version">{crumb}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<span class="status"><StatusDot /> AUTO PIPELINE</span>
|
<span class="status"><StatusDot /> AUTO PIPELINE</span>
|
||||||
<IconButton icon={CircleHelp} label="Help" onclick={() => {}} />
|
<IconButton
|
||||||
|
icon={CircleHelp}
|
||||||
|
label="Help"
|
||||||
|
variant="bare"
|
||||||
|
onclick={() => {}}
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={theme === "light" ? Moon : Sun}
|
icon={theme === "light" ? Moon : Sun}
|
||||||
label="Toggle theme"
|
label="Toggle theme"
|
||||||
|
variant="bare"
|
||||||
onclick={ontoggle}
|
onclick={ontoggle}
|
||||||
/>
|
/>
|
||||||
<Segmented
|
<div class="lang" role="group" aria-label="Language">
|
||||||
bind:value={lang}
|
<button
|
||||||
options={[
|
type="button"
|
||||||
{ value: "RU", label: "RU" },
|
class="lang-btn"
|
||||||
{ value: "EN", label: "EN" },
|
class:active={lang === "RU"}
|
||||||
]}
|
onclick={() => (lang = "RU")}>RU</button
|
||||||
/>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lang-btn"
|
||||||
|
class:active={lang === "EN"}
|
||||||
|
onclick={() => (lang = "EN")}>EN</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -55,6 +69,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font: 600 14px var(--font-mono);
|
font: 600 14px var(--font-mono);
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
background: var(--blue);
|
background: var(--blue);
|
||||||
@@ -65,6 +80,11 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
.version {
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
.top-actions {
|
.top-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -78,4 +98,26 @@
|
|||||||
font: 10px var(--font-mono);
|
font: 10px var(--font-mono);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
.lang {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lang-btn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 6px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lang-btn + .lang-btn {
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.lang-btn.active {
|
||||||
|
background: var(--foreground);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { TOOL_ICONS } from '$lib/tools/tool-icons';
|
import { TOOL_ICONS } from "$lib/tools/tool-icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
toolId: string;
|
toolId: string;
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
selected = false,
|
selected = false,
|
||||||
href,
|
href,
|
||||||
onActivate,
|
onActivate,
|
||||||
onHover
|
onHover,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const Icon = $derived(TOOL_ICONS[toolId]);
|
const Icon = $derived(TOOL_ICONS[toolId]);
|
||||||
@@ -35,12 +35,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if href}
|
{#if href}
|
||||||
<a
|
<a {href} class="card" class:selected onmousemove={onHover}>
|
||||||
{href}
|
|
||||||
class="card"
|
|
||||||
class:selected
|
|
||||||
onmousemove={onHover}
|
|
||||||
>
|
|
||||||
{@render content()}
|
{@render content()}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -53,7 +48,7 @@
|
|||||||
onclick={onActivate}
|
onclick={onActivate}
|
||||||
onmousemove={onHover}
|
onmousemove={onHover}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') onActivate?.();
|
if (e.key === "Enter") onActivate?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{@render content()}
|
{@render content()}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isChainable, TOOLS } from '$lib/registry';
|
import { isChainable, TOOLS } from "$lib/registry";
|
||||||
import { LOCALE_TAGS } from '$lib/i18n/dict';
|
import { LOCALE_TAGS } from "$lib/i18n/dict";
|
||||||
import { getLocale } from '$lib/i18n/locale.svelte';
|
import { getLocale } from "$lib/i18n/locale.svelte";
|
||||||
import { normalizeForSearch, scoreDoc } from '$lib/i18n/matching';
|
import { normalizeForSearch, scoreDoc } from "$lib/i18n/matching";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
import { toolDescription, toolSearchDoc, toolTitle } from '$lib/i18n/tool-strings';
|
import {
|
||||||
import ToolCard from './ToolCard.svelte';
|
toolDescription,
|
||||||
|
toolSearchDoc,
|
||||||
|
toolTitle,
|
||||||
|
} from "$lib/i18n/tool-strings";
|
||||||
|
import ToolCard from "./ToolCard.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSelect: (toolId: string) => void;
|
onSelect: (toolId: string) => void;
|
||||||
@@ -15,9 +19,11 @@
|
|||||||
|
|
||||||
let { onSelect, chainableOnly = false }: Props = $props();
|
let { onSelect, chainableOnly = false }: Props = $props();
|
||||||
|
|
||||||
const candidates = $derived(chainableOnly ? TOOLS.filter(isChainable) : TOOLS);
|
const candidates = $derived(
|
||||||
|
chainableOnly ? TOOLS.filter(isChainable) : TOOLS,
|
||||||
|
);
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state("");
|
||||||
let activeIndex = $state(0);
|
let activeIndex = $state(0);
|
||||||
let listOpen = $state(false);
|
let listOpen = $state(false);
|
||||||
|
|
||||||
@@ -40,7 +46,7 @@
|
|||||||
title: toolTitle(tool),
|
title: toolTitle(tool),
|
||||||
description: toolDescription(tool),
|
description: toolDescription(tool),
|
||||||
popularity: tool.popularity ?? 50,
|
popularity: tool.popularity ?? 50,
|
||||||
score: s
|
score: s,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,31 +55,31 @@
|
|||||||
(a, b) =>
|
(a, b) =>
|
||||||
b.score - a.score ||
|
b.score - a.score ||
|
||||||
b.popularity - a.popularity ||
|
b.popularity - a.popularity ||
|
||||||
collator.compare(a.title, b.title)
|
collator.compare(a.title, b.title),
|
||||||
);
|
);
|
||||||
return found.slice(0, 12);
|
return found.slice(0, 12);
|
||||||
});
|
});
|
||||||
|
|
||||||
function choose(id: string) {
|
function choose(id: string) {
|
||||||
listOpen = false;
|
listOpen = false;
|
||||||
query = '';
|
query = "";
|
||||||
activeIndex = 0;
|
activeIndex = 0;
|
||||||
onSelect(id);
|
onSelect(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
function onKeydown(event: KeyboardEvent) {
|
||||||
if (!listOpen || matches.length === 0) return;
|
if (!listOpen || matches.length === 0) return;
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
activeIndex = (activeIndex + 1) % matches.length;
|
activeIndex = (activeIndex + 1) % matches.length;
|
||||||
} else if (event.key === 'ArrowUp') {
|
} else if (event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
activeIndex = (activeIndex - 1 + matches.length) % matches.length;
|
activeIndex = (activeIndex - 1 + matches.length) % matches.length;
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const match = matches[Math.min(activeIndex, matches.length - 1)];
|
const match = matches[Math.min(activeIndex, matches.length - 1)];
|
||||||
if (match) choose(match.id);
|
if (match) choose(match.id);
|
||||||
} else if (event.key === 'Escape') {
|
} else if (event.key === "Escape") {
|
||||||
listOpen = false;
|
listOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,14 +95,14 @@
|
|||||||
activeIndex = 0;
|
activeIndex = 0;
|
||||||
}}
|
}}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
placeholder={t('search.placeholder')}
|
placeholder={t("search.placeholder")}
|
||||||
aria-label={t('search.aria')}
|
aria-label={t("search.aria")}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={listOpen}
|
aria-expanded={listOpen}
|
||||||
aria-controls="tool-search-list"
|
aria-controls="tool-search-list"
|
||||||
/>
|
/>
|
||||||
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
||||||
<p class="none text-muted">{t('search.nothingFound')}</p>
|
<p class="none text-muted">{t("search.nothingFound")}</p>
|
||||||
{:else if listOpen && matches.length > 0}
|
{:else if listOpen && matches.length > 0}
|
||||||
<div id="tool-search-list" class="cards" role="listbox">
|
<div id="tool-search-list" class="cards" role="listbox">
|
||||||
{#each matches as match, index (match.id)}
|
{#each matches as match, index (match.id)}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/registry";
|
||||||
import type { ImageInfo } from '$lib/core/analyze';
|
import type { ImageInfo } from "$lib/core/analyze";
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
import { toolTitle } from '$lib/i18n/tool-strings';
|
import { toolTitle } from "$lib/i18n/tool-strings";
|
||||||
import { TOOL_ICONS } from '$lib/tools/tool-icons';
|
import { TOOL_ICONS } from "$lib/tools/tool-icons";
|
||||||
import DownloadButton from '../DownloadButton.svelte';
|
import DownloadButton from "../DownloadButton.svelte";
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from "../ui/Button.svelte";
|
||||||
import EmptyState from '../ui/EmptyState.svelte';
|
import EmptyState from "../ui/EmptyState.svelte";
|
||||||
import OverlayCard from '../tool/OverlayCard.svelte';
|
import OverlayCard from "../tool/OverlayCard.svelte";
|
||||||
import ParamsCard from '../tool/ParamsCard.svelte';
|
import ParamsCard from "../tool/ParamsCard.svelte";
|
||||||
import ResultCard from '../tool/ResultCard.svelte';
|
import ResultCard from "../tool/ResultCard.svelte";
|
||||||
import SourceCard from '../tool/SourceCard.svelte';
|
import SourceCard from "../tool/SourceCard.svelte";
|
||||||
import TextInputCard from '../tool/TextInputCard.svelte';
|
import TextInputCard from "../tool/TextInputCard.svelte";
|
||||||
import Preview from '../Preview.svelte';
|
import Preview from "../Preview.svelte";
|
||||||
import type { StageStatus } from './stage-props';
|
import type { StageStatus } from "./stage-props";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'base' | 'chain';
|
mode: "base" | "chain";
|
||||||
header?: Snippet;
|
header?: Snippet;
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
source?: PixelImage | null;
|
source?: PixelImage | null;
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
source = null,
|
source = null,
|
||||||
displayImage = null,
|
displayImage = null,
|
||||||
info = null,
|
info = null,
|
||||||
status = 'idle',
|
status = "idle",
|
||||||
isInfo = false,
|
isInfo = false,
|
||||||
isSourceless = false,
|
isSourceless = false,
|
||||||
isTextSource = false,
|
isTextSource = false,
|
||||||
@@ -98,43 +98,59 @@
|
|||||||
onRemove,
|
onRemove,
|
||||||
onError,
|
onError,
|
||||||
onAddStep,
|
onAddStep,
|
||||||
onRemoveChain
|
onRemoveChain,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isChain = $derived(mode === 'chain');
|
const isChain = $derived(mode === "chain");
|
||||||
const format = $derived(outputOf(tool));
|
const format = $derived(outputOf(tool));
|
||||||
const safeParams = $derived(sanitizeParams(tool, values ?? {}));
|
const safeParams = $derived(sanitizeParams(tool, values ?? {}));
|
||||||
const StepIcon = $derived(TOOL_ICONS[tool.id]);
|
const StepIcon = $derived(TOOL_ICONS[tool.id]);
|
||||||
const showParams = $derived(
|
const showParams = $derived(
|
||||||
mode === 'chain'
|
mode === "chain"
|
||||||
? tool.params.length > 0
|
? tool.params.length > 0
|
||||||
: (source !== null || isSourceless) &&
|
: (source !== null || isSourceless) &&
|
||||||
!isInfo &&
|
!isInfo &&
|
||||||
(tool.params.length > 0 || hasMask)
|
(tool.params.length > 0 || hasMask),
|
||||||
);
|
);
|
||||||
|
|
||||||
const leftLegend = $derived(isChain ? t('chain.inputLegend') : t('toolPage.legendSource'));
|
const leftLegend = $derived(
|
||||||
const midLegend = $derived(isChain ? t('chain.paramsLegend') : t('toolPage.legendParams'));
|
isChain ? t("chain.inputLegend") : t("toolPage.legendSource"),
|
||||||
|
);
|
||||||
|
const midLegend = $derived(
|
||||||
|
isChain ? t("chain.paramsLegend") : t("toolPage.legendParams"),
|
||||||
|
);
|
||||||
const rightLegend = $derived(
|
const rightLegend = $derived(
|
||||||
isChain ? t('chain.resultLegend') : isInfo ? t('toolPage.legendSummary') : t('toolPage.legendResult')
|
isChain
|
||||||
|
? t("chain.resultLegend")
|
||||||
|
: isInfo
|
||||||
|
? t("toolPage.legendSummary")
|
||||||
|
: t("toolPage.legendResult"),
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel stage-grid" class:no-params={!showParams} class:single={!isChain && !!isSourceless}>
|
<div
|
||||||
|
class="panel stage-grid"
|
||||||
|
class:no-params={!showParams}
|
||||||
|
class:single={!isChain && !!isSourceless}
|
||||||
|
>
|
||||||
{#if header}
|
{#if header}
|
||||||
{@render header()}
|
{@render header()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isChain}
|
{#if isChain}
|
||||||
<section class="pane">
|
<section class="pane">
|
||||||
<span class="edge-legend pane-legend" aria-hidden="true">{leftLegend}</span>
|
<span class="edge-legend pane-legend" aria-hidden="true"
|
||||||
|
>{leftLegend}</span
|
||||||
|
>
|
||||||
<div class="pane-body">
|
<div class="pane-body">
|
||||||
<Preview image={input} />
|
<Preview image={input} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if !isSourceless}
|
{:else if !isSourceless}
|
||||||
<section class="pane">
|
<section class="pane">
|
||||||
<span class="edge-legend pane-legend" aria-hidden="true">{leftLegend}</span>
|
<span class="edge-legend pane-legend" aria-hidden="true"
|
||||||
|
>{leftLegend}</span
|
||||||
|
>
|
||||||
<div class="pane-body">
|
<div class="pane-body">
|
||||||
{#if isTextSource && !source}
|
{#if isTextSource && !source}
|
||||||
<TextInputCard onSubmit={(text) => onTextSubmit?.(text)} />
|
<TextInputCard onSubmit={(text) => onTextSubmit?.(text)} />
|
||||||
@@ -162,34 +178,50 @@
|
|||||||
|
|
||||||
{#if showParams}
|
{#if showParams}
|
||||||
<section class="pane pane-params">
|
<section class="pane pane-params">
|
||||||
<span class="edge-legend pane-legend" aria-hidden="true">{midLegend}</span>
|
<span class="edge-legend pane-legend" aria-hidden="true">{midLegend}</span
|
||||||
|
>
|
||||||
<div class="pane-body">
|
<div class="pane-body">
|
||||||
<ParamsCard {tool} params={tool.params} bind:values {pipetteTargetId} onPipetteToggle={(id) => handlePipetteToggle?.(id)} {hasMask} bind:showMask />
|
<ParamsCard
|
||||||
|
{tool}
|
||||||
|
params={tool.params}
|
||||||
|
bind:values
|
||||||
|
{pipetteTargetId}
|
||||||
|
onPipetteToggle={(id) => handlePipetteToggle?.(id)}
|
||||||
|
{hasMask}
|
||||||
|
bind:showMask
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="pane">
|
<section class="pane">
|
||||||
<span class="edge-legend pane-legend" aria-hidden="true">{rightLegend}</span>
|
<span class="edge-legend pane-legend" aria-hidden="true">{rightLegend}</span
|
||||||
|
>
|
||||||
<div class="pane-body">
|
<div class="pane-body">
|
||||||
{#if isChain}
|
{#if isChain}
|
||||||
{#if busy && !result}
|
{#if busy && !result}
|
||||||
<EmptyState title={t('chain.busyTitle')} hint={t('chain.busyHint')} />
|
<EmptyState title={t("chain.busyTitle")} hint={t("chain.busyHint")} />
|
||||||
{:else}
|
{:else}
|
||||||
<Preview image={result} />
|
<Preview image={result} />
|
||||||
{#if busy}
|
{#if busy}
|
||||||
<span class="recalc" aria-live="polite">{t('resultCard.recalc')}</span>
|
<span class="recalc" aria-live="polite"
|
||||||
|
>{t("resultCard.recalc")}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
image={result}
|
image={result}
|
||||||
format={format}
|
{format}
|
||||||
baseName="{index + 2}-{tool.id}"
|
baseName="{index + 2}-{tool.id}"
|
||||||
params={safeParams}
|
params={safeParams}
|
||||||
onError={(e) => showError?.(e)}
|
onError={(e) => showError?.(e)}
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
|
<Button
|
||||||
{isLast ? t('resultCard.nextTool') : t('resultCard.breakChain')}
|
variant="secondary"
|
||||||
|
fullWidth
|
||||||
|
onclick={isLast ? onAddStep : onRemoveChain}
|
||||||
|
>
|
||||||
|
{isLast ? t("resultCard.nextTool") : t("resultCard.breakChain")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -199,11 +231,11 @@
|
|||||||
sourceLoaded={isSourceless ? true : !!source}
|
sourceLoaded={isSourceless ? true : !!source}
|
||||||
{status}
|
{status}
|
||||||
{result}
|
{result}
|
||||||
displayImage={displayImage}
|
{displayImage}
|
||||||
{info}
|
{info}
|
||||||
{isInfo}
|
{isInfo}
|
||||||
params={sanitized}
|
params={sanitized}
|
||||||
textResult={textResult}
|
{textResult}
|
||||||
onChainToggle={canChainBase ? toggleChain : undefined}
|
onChainToggle={canChainBase ? toggleChain : undefined}
|
||||||
{hasChain}
|
{hasChain}
|
||||||
onDownloadError={(e) => showError?.(e)}
|
onDownloadError={(e) => showError?.(e)}
|
||||||
@@ -276,7 +308,7 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-params :global(input[type='range']) {
|
.pane-params :global(input[type="range"]) {
|
||||||
order: -1;
|
order: -1;
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -306,7 +338,10 @@
|
|||||||
|
|
||||||
@media (min-width: 75rem) {
|
@media (min-width: 75rem) {
|
||||||
.stage-grid:not(.no-params):not(.single) {
|
.stage-grid:not(.no-params):not(.single) {
|
||||||
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 18rem) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 18rem) minmax(
|
||||||
|
0,
|
||||||
|
1fr
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-grid.no-params:not(.single),
|
.stage-grid.no-params:not(.single),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
import ParamsCard from '../tool/ParamsCard.svelte';
|
import ParamsCard from "../tool/ParamsCard.svelte";
|
||||||
import ResultCard from '../tool/ResultCard.svelte';
|
import ResultCard from "../tool/ResultCard.svelte";
|
||||||
import SourceCard from '../tool/SourceCard.svelte';
|
import SourceCard from "../tool/SourceCard.svelte";
|
||||||
import TextInputCard from '../tool/TextInputCard.svelte';
|
import TextInputCard from "../tool/TextInputCard.svelte";
|
||||||
import type { StageProps } from './stage-props';
|
import type { StageProps } from "./stage-props";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tool,
|
tool,
|
||||||
@@ -32,14 +32,16 @@
|
|||||||
handlePipetteToggle,
|
handlePipetteToggle,
|
||||||
handlePickColor,
|
handlePickColor,
|
||||||
showError,
|
showError,
|
||||||
errorMessage
|
errorMessage,
|
||||||
}: StageProps = $props();
|
}: StageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel tool-block">
|
<div class="panel tool-block">
|
||||||
<div class="tool-stage" class:single={isSourceless}>
|
<div class="tool-stage" class:single={isSourceless}>
|
||||||
{#if !isSourceless}
|
{#if !isSourceless}
|
||||||
<span class="edge-legend source-legend" aria-hidden="true">{t('toolPage.legendSource')}</span>
|
<span class="edge-legend source-legend" aria-hidden="true"
|
||||||
|
>{t("toolPage.legendSource")}</span
|
||||||
|
>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
{#if isTextSource && !source}
|
{#if isTextSource && !source}
|
||||||
<TextInputCard onSubmit={onTextSubmit} />
|
<TextInputCard onSubmit={onTextSubmit} />
|
||||||
@@ -56,7 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="edge-legend result-legend" aria-hidden="true">
|
<span class="edge-legend result-legend" aria-hidden="true">
|
||||||
{isInfo ? t('toolPage.legendSummary') : t('toolPage.legendResult')}
|
{isInfo ? t("toolPage.legendSummary") : t("toolPage.legendResult")}
|
||||||
</span>
|
</span>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<ResultCard
|
<ResultCard
|
||||||
@@ -64,11 +66,11 @@
|
|||||||
sourceLoaded={isSourceless ? true : !!source}
|
sourceLoaded={isSourceless ? true : !!source}
|
||||||
{status}
|
{status}
|
||||||
{result}
|
{result}
|
||||||
displayImage={displayImage}
|
{displayImage}
|
||||||
{info}
|
{info}
|
||||||
{isInfo}
|
{isInfo}
|
||||||
params={sanitized}
|
params={sanitized}
|
||||||
textResult={textResult}
|
{textResult}
|
||||||
onChainToggle={canChainBase ? toggleChain : undefined}
|
onChainToggle={canChainBase ? toggleChain : undefined}
|
||||||
{hasChain}
|
{hasChain}
|
||||||
onDownloadError={showError}
|
onDownloadError={showError}
|
||||||
@@ -78,7 +80,9 @@
|
|||||||
|
|
||||||
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
|
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
|
||||||
<div class="params-sep">
|
<div class="params-sep">
|
||||||
<span class="edge-legend" aria-hidden="true">{t('toolPage.legendParams')}</span>
|
<span class="edge-legend" aria-hidden="true"
|
||||||
|
>{t("toolPage.legendParams")}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<ParamsCard
|
<ParamsCard
|
||||||
{tool}
|
{tool}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ImageInfo } from '$lib/core/analyze';
|
import type { ImageInfo } from "$lib/core/analyze";
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import type { ToolEntry } from '$lib/registry';
|
import type { ToolEntry } from "$lib/registry";
|
||||||
|
|
||||||
export type StageStatus = 'idle' | 'loaded' | 'processing' | 'error';
|
export type StageStatus = "idle" | "loaded" | "processing" | "error";
|
||||||
|
|
||||||
export interface StageProps {
|
export interface StageProps {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import DropZone from '../DropZone.svelte';
|
import DropZone from "../DropZone.svelte";
|
||||||
import Preview from '../Preview.svelte';
|
import Preview from "../Preview.svelte";
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from "../ui/Button.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
overlay: PixelImage | null;
|
overlay: PixelImage | null;
|
||||||
@@ -16,13 +16,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overlay-card">
|
<div class="overlay-card">
|
||||||
<span class="edge-legend overlay-legend" aria-hidden="true">{t('ui.overlayTitle')}</span>
|
<span class="edge-legend overlay-legend" aria-hidden="true"
|
||||||
|
>{t("ui.overlayTitle")}</span
|
||||||
|
>
|
||||||
{#if !overlay}
|
{#if !overlay}
|
||||||
<DropZone {onFile} {onError} label={t('ui.overlayDrop')} />
|
<DropZone {onFile} {onError} label={t("ui.overlayDrop")} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="preview-wrap">
|
<div class="preview-wrap">
|
||||||
<Preview image={overlay} />
|
<Preview image={overlay} />
|
||||||
<Button variant="secondary" onclick={onClear}>{t('ui.overlayRemove')}</Button>
|
<Button variant="secondary" onclick={onClear}
|
||||||
|
>{t("ui.overlayRemove")}</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ParamForm from '../ParamForm.svelte';
|
import ParamForm from "../ParamForm.svelte";
|
||||||
import type { ParamDef, ToolEntry } from '$lib/registry';
|
import type { ParamDef, ToolEntry } from "$lib/registry";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
pipetteTargetId = null,
|
pipetteTargetId = null,
|
||||||
onPipetteToggle,
|
onPipetteToggle,
|
||||||
hasMask = false,
|
hasMask = false,
|
||||||
showMask = $bindable(false)
|
showMask = $bindable(false),
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
bind:showMask
|
bind:showMask
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="hint text-caption text-muted">{t('paramsCard.noParams')}</p>
|
<p class="hint text-caption text-muted">{t("paramsCard.noParams")}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
image: PixelImage;
|
image: PixelImage;
|
||||||
@@ -25,10 +25,16 @@
|
|||||||
|
|
||||||
function ensureSource(): HTMLCanvasElement | null {
|
function ensureSource(): HTMLCanvasElement | null {
|
||||||
if (!srcCanvas || srcKey !== image) {
|
if (!srcCanvas || srcKey !== image) {
|
||||||
srcCanvas = document.createElement('canvas');
|
srcCanvas = document.createElement("canvas");
|
||||||
srcCanvas.width = image.width;
|
srcCanvas.width = image.width;
|
||||||
srcCanvas.height = image.height;
|
srcCanvas.height = image.height;
|
||||||
srcCanvas.getContext('2d')?.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
|
srcCanvas
|
||||||
|
.getContext("2d")
|
||||||
|
?.putImageData(
|
||||||
|
new ImageData(image.data, image.width, image.height),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
srcKey = image;
|
srcKey = image;
|
||||||
}
|
}
|
||||||
return srcCanvas;
|
return srcCanvas;
|
||||||
@@ -38,8 +44,12 @@
|
|||||||
return Math.min(max, Math.max(min, v));
|
return Math.min(max, Math.max(min, v));
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockX = $derived(clamp(px - HALF, 0, Math.max(0, image.width - BLOCK)));
|
const blockX = $derived(
|
||||||
const blockY = $derived(clamp(py - HALF, 0, Math.max(0, image.height - BLOCK)));
|
clamp(px - HALF, 0, Math.max(0, image.width - BLOCK)),
|
||||||
|
);
|
||||||
|
const blockY = $derived(
|
||||||
|
clamp(py - HALF, 0, Math.max(0, image.height - BLOCK)),
|
||||||
|
);
|
||||||
const flipBelow = $derived(clientY < SIZE + 24);
|
const flipBelow = $derived(clientY < SIZE + 24);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -47,12 +57,12 @@
|
|||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
loupeCanvas.width = SIZE * dpr;
|
loupeCanvas.width = SIZE * dpr;
|
||||||
loupeCanvas.height = SIZE * dpr;
|
loupeCanvas.height = SIZE * dpr;
|
||||||
const ctx = loupeCanvas.getContext('2d');
|
const ctx = loupeCanvas.getContext("2d");
|
||||||
const src = ensureSource();
|
const src = ensureSource();
|
||||||
if (!ctx || !src) return;
|
if (!ctx || !src) return;
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.fillStyle = '#111318';
|
ctx.fillStyle = "#111318";
|
||||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
src,
|
src,
|
||||||
@@ -63,11 +73,16 @@
|
|||||||
CENTER + (blockX - px) * ZOOM,
|
CENTER + (blockX - px) * ZOOM,
|
||||||
CENTER + (blockY - py) * ZOOM,
|
CENTER + (blockY - py) * ZOOM,
|
||||||
BLOCK * ZOOM,
|
BLOCK * ZOOM,
|
||||||
BLOCK * ZOOM
|
BLOCK * ZOOM,
|
||||||
);
|
);
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeRect(CENTER - ZOOM / 2 + 0.5, CENTER - ZOOM / 2 + 0.5, ZOOM, ZOOM);
|
ctx.strokeRect(
|
||||||
|
CENTER - ZOOM / 2 + 0.5,
|
||||||
|
CENTER - ZOOM / 2 + 0.5,
|
||||||
|
ZOOM,
|
||||||
|
ZOOM,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -77,7 +92,8 @@
|
|||||||
style="left:{clientX}px;top:{clientY}px"
|
style="left:{clientX}px;top:{clientY}px"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<canvas bind:this={loupeCanvas} style="width:{SIZE}px;height:{SIZE}px"></canvas>
|
<canvas bind:this={loupeCanvas} style="width:{SIZE}px;height:{SIZE}px"
|
||||||
|
></canvas>
|
||||||
<p class="hex">{hex}</p>
|
<p class="hex">{hex}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from "../ui/Button.svelte";
|
||||||
import DownloadButton from '../DownloadButton.svelte';
|
import DownloadButton from "../DownloadButton.svelte";
|
||||||
import EmptyState from '../ui/EmptyState.svelte';
|
import EmptyState from "../ui/EmptyState.svelte";
|
||||||
import InfoPanel from '../InfoPanel.svelte';
|
import InfoPanel from "../InfoPanel.svelte";
|
||||||
import Preview from '../Preview.svelte';
|
import Preview from "../Preview.svelte";
|
||||||
import TextResult from './TextResult.svelte';
|
import TextResult from "./TextResult.svelte";
|
||||||
import type { ImageInfo } from '$lib/core/analyze';
|
import type { ImageInfo } from "$lib/core/analyze";
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { outputOf, type ToolEntry } from '$lib/registry';
|
import { outputOf, type ToolEntry } from "$lib/registry";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
type Status = "idle" | "loaded" | "processing" | "error";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
@@ -39,20 +39,23 @@
|
|||||||
textResult,
|
textResult,
|
||||||
onChainToggle,
|
onChainToggle,
|
||||||
hasChain = false,
|
hasChain = false,
|
||||||
onDownloadError
|
onDownloadError,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{#if !sourceLoaded}
|
{#if !sourceLoaded}
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<EmptyState title={t('resultCard.emptyTitle')} hint={t('resultCard.emptyHint')} />
|
<EmptyState
|
||||||
|
title={t("resultCard.emptyTitle")}
|
||||||
|
hint={t("resultCard.emptyHint")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if !isInfo && status === 'processing' && !result}
|
{:else if !isInfo && status === "processing" && !result}
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t('resultCard.processingTitle')}
|
title={t("resultCard.processingTitle")}
|
||||||
hint={t('resultCard.processingHint')}
|
hint={t("resultCard.processingHint")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if isInfo}
|
{:else if isInfo}
|
||||||
@@ -61,7 +64,7 @@
|
|||||||
<InfoPanel {info} />
|
<InfoPanel {info} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if tool.resultType === 'text'}
|
{:else if tool.resultType === "text"}
|
||||||
<div class="media">
|
<div class="media">
|
||||||
{#if textResult !== null}
|
{#if textResult !== null}
|
||||||
<TextResult text={textResult} filename={tool.id} toolId={tool.id} />
|
<TextResult text={textResult} filename={tool.id} toolId={tool.id} />
|
||||||
@@ -70,8 +73,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<Preview image={displayImage} />
|
<Preview image={displayImage} />
|
||||||
{#if status === 'processing'}
|
{#if status === "processing"}
|
||||||
<span class="recalc" aria-live="polite">{t('resultCard.recalc')}</span>
|
<span class="recalc" aria-live="polite">{t("resultCard.recalc")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
@@ -84,7 +87,7 @@
|
|||||||
/>
|
/>
|
||||||
{#if onChainToggle}
|
{#if onChainToggle}
|
||||||
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
||||||
{hasChain ? t('resultCard.breakChain') : t('resultCard.nextTool')}
|
{hasChain ? t("resultCard.breakChain") : t("resultCard.nextTool")}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
import DropOverlay from '../DropOverlay.svelte';
|
import DropOverlay from "../DropOverlay.svelte";
|
||||||
import DropZone from '../DropZone.svelte';
|
import DropZone from "../DropZone.svelte";
|
||||||
import Preview from '../Preview.svelte';
|
import Preview from "../Preview.svelte";
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from "../ui/Button.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source: PixelImage | null;
|
source: PixelImage | null;
|
||||||
@@ -15,7 +15,14 @@
|
|||||||
onPickColor?: (hex: string) => void;
|
onPickColor?: (hex: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { source, onFile, onError, onReset, pipetteActive = false, onPickColor }: Props = $props();
|
let {
|
||||||
|
source,
|
||||||
|
onFile,
|
||||||
|
onError,
|
||||||
|
onReset,
|
||||||
|
pipetteActive = false,
|
||||||
|
onPickColor,
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -24,9 +31,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<DropOverlay {onFile} {onError}>
|
<DropOverlay {onFile} {onError}>
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<Preview image={source} pipetteActive={pipetteActive} onPickColor={onPickColor} />
|
<Preview image={source} {pipetteActive} {onPickColor} />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onclick={onReset}>{t('sourceCard.replaceImage')}</Button>
|
<Button variant="secondary" onclick={onReset}
|
||||||
|
>{t("sourceCard.replaceImage")}</Button
|
||||||
|
>
|
||||||
</DropOverlay>
|
</DropOverlay>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '../ui/Button.svelte';
|
import Button from "../ui/Button.svelte";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
let { onSubmit }: Props = $props();
|
let { onSubmit }: Props = $props();
|
||||||
|
|
||||||
let text = $state('');
|
let text = $state("");
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (text.trim().length === 0) return;
|
if (text.trim().length === 0) return;
|
||||||
@@ -17,16 +17,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="heading-section">{t('textInput.heading')}</h2>
|
<h2 class="heading-section">{t("textInput.heading")}</h2>
|
||||||
<textarea
|
<textarea
|
||||||
class="input"
|
class="input"
|
||||||
rows="8"
|
rows="8"
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
placeholder={t('textInput.placeholder')}
|
placeholder={t("textInput.placeholder")}
|
||||||
aria-label={t('textInput.aria')}
|
aria-label={t("textInput.aria")}></textarea>
|
||||||
></textarea>
|
<Button
|
||||||
<Button variant="secondary" onclick={submit} disabled={text.trim().length === 0}>
|
variant="secondary"
|
||||||
{t('textInput.decode')}
|
onclick={submit}
|
||||||
|
disabled={text.trim().length === 0}
|
||||||
|
>
|
||||||
|
{t("textInput.decode")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloadBlob } from '$lib/core/io';
|
import { downloadBlob } from "$lib/core/io";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
import { getMergedDict } from '$lib/i18n/locale.svelte';
|
import { getMergedDict } from "$lib/i18n/locale.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
let { text, filename, toolId }: Props = $props();
|
let { text, filename, toolId }: Props = $props();
|
||||||
|
|
||||||
const shown = $derived(
|
const shown = $derived(
|
||||||
toolId ? (getMergedDict().tools[toolId]?.results?.[text] ?? text) : text
|
toolId ? (getMergedDict().tools[toolId]?.results?.[text] ?? text) : text,
|
||||||
);
|
);
|
||||||
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
@@ -24,17 +24,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
downloadBlob(new Blob([shown], { type: 'text/plain' }), `${filename}.txt`);
|
downloadBlob(new Blob([shown], { type: "text/plain" }), `${filename}.txt`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel text-result">
|
<div class="panel text-result">
|
||||||
<textarea class="output" rows="10" readonly value={shown} aria-label={t('textResult.outputAria')}></textarea>
|
<textarea
|
||||||
|
class="output"
|
||||||
|
rows="10"
|
||||||
|
readonly
|
||||||
|
value={shown}
|
||||||
|
aria-label={t("textResult.outputAria")}></textarea>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="secondary" onclick={copy}>
|
<button type="button" class="secondary" onclick={copy}>
|
||||||
{copied ? t('textResult.copied') : t('textResult.copy')}
|
{copied ? t("textResult.copied") : t("textResult.copy")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="primary" onclick={download}>{t('textResult.downloadTxt')}</button>
|
<button type="button" class="primary" onclick={download}
|
||||||
|
>{t("textResult.downloadTxt")}</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: 'primary' | 'secondary';
|
variant?: "primary" | "secondary";
|
||||||
type?: 'button' | 'submit';
|
type?: "button" | "submit";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
busyText?: string;
|
busyText?: string;
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
variant = 'primary',
|
variant = "primary",
|
||||||
type = 'button',
|
type = "button",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
busy = false,
|
busy = false,
|
||||||
busyText = '',
|
busyText = "",
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
onclick,
|
onclick,
|
||||||
children
|
children,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
class={fullWidth ? `${variant} fullwidth` : variant}
|
class={fullWidth ? `${variant} fullwidth` : variant}
|
||||||
aria-busy={busy}
|
aria-busy={busy}
|
||||||
disabled={disabled || busy}
|
disabled={disabled || busy}
|
||||||
onclick={onclick}
|
{onclick}
|
||||||
>
|
>
|
||||||
{#if busy && busyText}
|
{#if busy && busyText}
|
||||||
{busyText}
|
{busyText}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
<label class="checkbox" for={id}>
|
<label class="checkbox" for={id}>
|
||||||
<input id={id} type="checkbox" bind:checked />
|
<input {id} type="checkbox" bind:checked />
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{#if hint}
|
{#if hint}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='checkbox'] {
|
input[type="checkbox"] {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Field from './Field.svelte';
|
import Field from "./Field.svelte";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,20 +11,26 @@
|
|||||||
onPipetteToggle?: () => void;
|
onPipetteToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, label, value = $bindable('#000000'), hint, pipetteActive = false, onPipetteToggle }:
|
let {
|
||||||
Props = $props();
|
id,
|
||||||
|
label,
|
||||||
|
value = $bindable("#000000"),
|
||||||
|
hint,
|
||||||
|
pipetteActive = false,
|
||||||
|
onPipetteToggle,
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
<Field {id} {label} {hint}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input id={id} class="control swatch" type="color" bind:value />
|
<input {id} class="control swatch" type="color" bind:value />
|
||||||
{#if onPipetteToggle}
|
{#if onPipetteToggle}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="pipette"
|
class="pipette"
|
||||||
aria-label={t('ui.pipette')}
|
aria-label={t("ui.pipette")}
|
||||||
aria-pressed={pipetteActive}
|
aria-pressed={pipetteActive}
|
||||||
title={t('ui.pipette')}
|
title={t("ui.pipette")}
|
||||||
onclick={onPipetteToggle}
|
onclick={onPipetteToggle}
|
||||||
>
|
>
|
||||||
◎
|
◎
|
||||||
@@ -73,7 +79,7 @@
|
|||||||
color: var(--link);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pipette[aria-pressed='true'] {
|
.pipette[aria-pressed="true"] {
|
||||||
border-color: var(--link);
|
border-color: var(--link);
|
||||||
color: var(--link);
|
color: var(--link);
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Field from './Field.svelte';
|
import Field from "./Field.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
hint?: string;
|
hint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, label, value = $bindable(''), options, hint }: Props = $props();
|
let { id, label, value = $bindable(""), options, hint }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
<Field {id} {label} {hint}>
|
||||||
<select id={id} class="control" bind:value>
|
<select {id} class="control" bind:value>
|
||||||
{#each options as option (option.value)}
|
{#each options as option (option.value)}
|
||||||
<option value={option.value}>{option.label}</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Field from './Field.svelte';
|
import Field from "./Field.svelte";
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from "$lib/i18n/t";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
default: defaultValue,
|
default: defaultValue,
|
||||||
hint
|
hint,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function decrement() {
|
function decrement() {
|
||||||
@@ -38,18 +38,30 @@
|
|||||||
if (defaultValue !== undefined) value = defaultValue;
|
if (defaultValue !== undefined) value = defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDisabled = $derived(defaultValue === undefined || value === defaultValue);
|
const resetDisabled = $derived(
|
||||||
|
defaultValue === undefined || value === defaultValue,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
<Field {id} {label} {hint}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="step" aria-label={t('ui.decrease')} onclick={decrement}>−</button>
|
|
||||||
<input id={id} type="range" min={min} max={max} step={step} bind:value />
|
|
||||||
<button type="button" class="step" aria-label={t('ui.increase')} onclick={increment}>+</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="step"
|
class="step"
|
||||||
aria-label={t('ui.reset')}
|
aria-label={t("ui.decrease")}
|
||||||
|
onclick={decrement}>−</button
|
||||||
|
>
|
||||||
|
<input {id} type="range" {min} {max} {step} bind:value />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
aria-label={t("ui.increase")}
|
||||||
|
onclick={increment}>+</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
aria-label={t("ui.reset")}
|
||||||
disabled={resetDisabled}
|
disabled={resetDisabled}
|
||||||
onclick={reset}
|
onclick={reset}
|
||||||
>
|
>
|
||||||
@@ -68,7 +80,7 @@
|
|||||||
max-width: 22rem;
|
max-width: 22rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='range'] {
|
input[type="range"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
accent-color: var(--link);
|
accent-color: var(--link);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Field from './Field.svelte';
|
import Field from "./Field.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
value?: string | number;
|
value?: string | number;
|
||||||
type?: 'number' | 'text';
|
type?: "number" | "text";
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
@@ -13,12 +13,30 @@
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, label, value = $bindable(''), type = 'text', min, max, step, hint, placeholder }: Props =
|
let {
|
||||||
$props();
|
id,
|
||||||
|
label,
|
||||||
|
value = $bindable(""),
|
||||||
|
type = "text",
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
hint,
|
||||||
|
placeholder,
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
<Field {id} {label} {hint}>
|
||||||
<input class="control" {id} {type} min={min} max={max} step={step} {placeholder} bind:value />
|
<input
|
||||||
|
class="control"
|
||||||
|
{id}
|
||||||
|
{type}
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
{placeholder}
|
||||||
|
bind:value
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,39 +1,53 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { rotate90, sampleBilinear } from './geometry';
|
import { rotate90, sampleBilinear } from "./geometry";
|
||||||
import { rotateFreeImage, skewImage, transformImage, zoomImage } from './affine';
|
import {
|
||||||
import { makeImage } from './test-helpers';
|
rotateFreeImage,
|
||||||
|
skewImage,
|
||||||
|
transformImage,
|
||||||
|
zoomImage,
|
||||||
|
} from "./affine";
|
||||||
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe('sampleBilinear', () => {
|
describe("sampleBilinear", () => {
|
||||||
it('целые координаты возвращают точный пиксель', () => {
|
it("целые координаты возвращают точный пиксель", () => {
|
||||||
const img = makeImage(2, 1, [
|
const img = makeImage(2, 1, [
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[0, 0, 255, 255]
|
[0, 0, 255, 255],
|
||||||
]);
|
]);
|
||||||
expect(sampleBilinear(img, 0, 0)).toEqual([255, 0, 0, 255]);
|
expect(sampleBilinear(img, 0, 0)).toEqual([255, 0, 0, 255]);
|
||||||
expect(sampleBilinear(img, 1, 0)).toEqual([0, 0, 255, 255]);
|
expect(sampleBilinear(img, 1, 0)).toEqual([0, 0, 255, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('дробная координата интерполирует', () => {
|
it("дробная координата интерполирует", () => {
|
||||||
const img = makeImage(2, 1, [
|
const img = makeImage(2, 1, [
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[200, 200, 200, 255]
|
[200, 200, 200, 255],
|
||||||
]);
|
]);
|
||||||
const [r] = sampleBilinear(img, 0.5, 0);
|
const [r] = sampleBilinear(img, 0.5, 0);
|
||||||
expect(r).toBeCloseTo(100, 0);
|
expect(r).toBeCloseTo(100, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('координаты за краем клампятся', () => {
|
it("координаты за краем клампятся", () => {
|
||||||
const img = makeImage(1, 1, [[7, 7, 7, 255]]);
|
const img = makeImage(1, 1, [[7, 7, 7, 255]]);
|
||||||
expect(sampleBilinear(img, -10, -10)).toEqual([7, 7, 7, 255]);
|
expect(sampleBilinear(img, -10, -10)).toEqual([7, 7, 7, 255]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rotateFreeImage', () => {
|
describe("rotateFreeImage", () => {
|
||||||
it('поворот на 180° даёт размеры не меньше исходных и непустой результат', () => {
|
it("поворот на 180° даёт размеры не меньше исходных и непустой результат", () => {
|
||||||
const img = makeImage(4, 3, [
|
const img = makeImage(4, 3, [
|
||||||
[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[128, 128, 128, 255], [64, 64, 64, 255], [32, 32, 32, 255], [200, 200, 200, 255],
|
[0, 255, 0, 255],
|
||||||
[10, 20, 30, 255], [40, 50, 60, 255], [70, 80, 90, 255], [100, 100, 100, 255]
|
[0, 0, 255, 255],
|
||||||
|
[255, 255, 0, 255],
|
||||||
|
[128, 128, 128, 255],
|
||||||
|
[64, 64, 64, 255],
|
||||||
|
[32, 32, 32, 255],
|
||||||
|
[200, 200, 200, 255],
|
||||||
|
[10, 20, 30, 255],
|
||||||
|
[40, 50, 60, 255],
|
||||||
|
[70, 80, 90, 255],
|
||||||
|
[100, 100, 100, 255],
|
||||||
]);
|
]);
|
||||||
const out = rotateFreeImage(img, 180);
|
const out = rotateFreeImage(img, 180);
|
||||||
expect(out.width).toBeGreaterThanOrEqual(img.width);
|
expect(out.width).toBeGreaterThanOrEqual(img.width);
|
||||||
@@ -46,12 +60,8 @@ describe('rotateFreeImage', () => {
|
|||||||
expect(opaque / total).toBeGreaterThan(0.8);
|
expect(opaque / total).toBeGreaterThan(0.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('поворот квадрата на 360° близок к оригиналу', () => {
|
it("поворот квадрата на 360° близок к оригиналу", () => {
|
||||||
const img = makeImage(
|
const img = makeImage(5, 5, new Array(25).fill([200, 100, 50, 255]));
|
||||||
5,
|
|
||||||
5,
|
|
||||||
new Array(25).fill([200, 100, 50, 255])
|
|
||||||
);
|
|
||||||
const out = rotateFreeImage(img, 360);
|
const out = rotateFreeImage(img, 360);
|
||||||
expect(out.width).toBeGreaterThanOrEqual(5);
|
expect(out.width).toBeGreaterThanOrEqual(5);
|
||||||
expect(out.height).toBeGreaterThanOrEqual(5);
|
expect(out.height).toBeGreaterThanOrEqual(5);
|
||||||
@@ -59,15 +69,17 @@ describe('rotateFreeImage', () => {
|
|||||||
for (let x = 0; x < 5; x++) {
|
for (let x = 0; x < 5; x++) {
|
||||||
const di = (y * out.width + x) * 4;
|
const di = (y * out.width + x) * 4;
|
||||||
for (let ch = 0; ch < 4; ch++) {
|
for (let ch = 0; ch < 4; ch++) {
|
||||||
expect(Math.abs(out.data[di + ch] - img.data[(y * 5 + x) * 4 + ch])).toBeLessThanOrEqual(4);
|
expect(
|
||||||
|
Math.abs(out.data[di + ch] - img.data[(y * 5 + x) * 4 + ch]),
|
||||||
|
).toBeLessThanOrEqual(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('skewImage', () => {
|
describe("skewImage", () => {
|
||||||
it('наклон X=45° расширяет холст до w+h−1 и сдвигает строки вправо', () => {
|
it("наклон X=45° расширяет холст до w+h−1 и сдвигает строки вправо", () => {
|
||||||
const img = makeImage(2, 2, new Array(4).fill([255, 0, 0, 255]));
|
const img = makeImage(2, 2, new Array(4).fill([255, 0, 0, 255]));
|
||||||
const out = skewImage(img, 45, 0);
|
const out = skewImage(img, 45, 0);
|
||||||
expect(out.width).toBe(3);
|
expect(out.width).toBe(3);
|
||||||
@@ -76,16 +88,20 @@ describe('skewImage', () => {
|
|||||||
for (let i = 3; i < out.data.length; i += 4) {
|
for (let i = 3; i < out.data.length; i += 4) {
|
||||||
if (out.data[i] === 255) {
|
if (out.data[i] === 255) {
|
||||||
opaque++;
|
opaque++;
|
||||||
expect([out.data[i - 3], out.data[i - 2], out.data[i - 1]]).toEqual([255, 0, 0]);
|
expect([out.data[i - 3], out.data[i - 2], out.data[i - 1]]).toEqual([
|
||||||
|
255, 0, 0,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(opaque).toBe(4);
|
expect(opaque).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('углы 0° — тождественное преобразование', () => {
|
it("углы 0° — тождественное преобразование", () => {
|
||||||
const img = makeImage(2, 2, [
|
const img = makeImage(2, 2, [
|
||||||
[255, 0, 0, 255], [0, 255, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[0, 0, 255, 255], [128, 128, 128, 255]
|
[0, 255, 0, 255],
|
||||||
|
[0, 0, 255, 255],
|
||||||
|
[128, 128, 128, 255],
|
||||||
]);
|
]);
|
||||||
const out = skewImage(img, 0, 0);
|
const out = skewImage(img, 0, 0);
|
||||||
expect(out.width).toBe(2);
|
expect(out.width).toBe(2);
|
||||||
@@ -93,22 +109,24 @@ describe('skewImage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('transformImage — заливка фона', () => {
|
describe("transformImage — заливка фона", () => {
|
||||||
it('сдвиг с белым фоном заполняет освободившийся край', () => {
|
it("сдвиг с белым фоном заполняет освободившийся край", () => {
|
||||||
const img = makeImage(2, 2, [
|
const img = makeImage(2, 2, [
|
||||||
[255, 0, 0, 255], [0, 255, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[0, 0, 255, 255], [128, 128, 128, 255]
|
[0, 255, 0, 255],
|
||||||
|
[0, 0, 255, 255],
|
||||||
|
[128, 128, 128, 255],
|
||||||
]);
|
]);
|
||||||
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2, '#ffffff');
|
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2, "#ffffff");
|
||||||
expect(out.width).toBe(2);
|
expect(out.width).toBe(2);
|
||||||
expect(out.height).toBe(2);
|
expect(out.height).toBe(2);
|
||||||
for (let y = 0; y < 2; y++) {
|
for (let y = 0; y < 2; y++) {
|
||||||
const di = (y * 2) * 4;
|
const di = y * 2 * 4;
|
||||||
expect([...out.data.slice(di, di + 4)]).toEqual([255, 255, 255, 255]);
|
expect([...out.data.slice(di, di + 4)]).toEqual([255, 255, 255, 255]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('без фона освободившийся край остаётся прозрачным', () => {
|
it("без фона освободившийся край остаётся прозрачным", () => {
|
||||||
const img = makeImage(2, 2, new Array(4).fill([10, 20, 30, 255]));
|
const img = makeImage(2, 2, new Array(4).fill([10, 20, 30, 255]));
|
||||||
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2);
|
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2);
|
||||||
expect(out.data[3]).toBe(0);
|
expect(out.data[3]).toBe(0);
|
||||||
|
|||||||
+25
-11
@@ -1,14 +1,14 @@
|
|||||||
import { parseHex } from './alpha';
|
import { parseHex } from "./alpha";
|
||||||
import { ToolError } from './errors';
|
import { ToolError } from "./errors";
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
||||||
import { sampleBilinear } from './geometry';
|
import { sampleBilinear } from "./geometry";
|
||||||
|
|
||||||
export type AffineMatrix = [number, number, number, number, number, number];
|
export type AffineMatrix = [number, number, number, number, number, number];
|
||||||
|
|
||||||
export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
|
export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
|
||||||
const det = a * d - b * c;
|
const det = a * d - b * c;
|
||||||
if (Math.abs(det) < 1e-12) {
|
if (Math.abs(det) < 1e-12) {
|
||||||
throw new ToolError('errors.badTransform');
|
throw new ToolError("errors.badTransform");
|
||||||
}
|
}
|
||||||
const ia = d / det;
|
const ia = d / det;
|
||||||
const ib = -b / det;
|
const ib = -b / det;
|
||||||
@@ -24,7 +24,7 @@ export function transformImage(
|
|||||||
dstToSrc: AffineMatrix,
|
dstToSrc: AffineMatrix,
|
||||||
outWidth: number,
|
outWidth: number,
|
||||||
outHeight: number,
|
outHeight: number,
|
||||||
bgHex?: string
|
bgHex?: string,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const [a, b, c, d, e, f] = dstToSrc;
|
const [a, b, c, d, e, f] = dstToSrc;
|
||||||
const out = createPixelImage(outWidth, outHeight);
|
const out = createPixelImage(outWidth, outHeight);
|
||||||
@@ -34,7 +34,12 @@ export function transformImage(
|
|||||||
const sx = a * x + c * y + e;
|
const sx = a * x + c * y + e;
|
||||||
const sy = b * x + d * y + f;
|
const sy = b * x + d * y + f;
|
||||||
const di = (y * outWidth + x) * 4;
|
const di = (y * outWidth + x) * 4;
|
||||||
if (sx < -EPS || sy < -EPS || sx > img.width - 1 + EPS || sy > img.height - 1 + EPS) {
|
if (
|
||||||
|
sx < -EPS ||
|
||||||
|
sy < -EPS ||
|
||||||
|
sx > img.width - 1 + EPS ||
|
||||||
|
sy > img.height - 1 + EPS
|
||||||
|
) {
|
||||||
if (bg) {
|
if (bg) {
|
||||||
out.data[di] = bg[0];
|
out.data[di] = bg[0];
|
||||||
out.data[di + 1] = bg[1];
|
out.data[di + 1] = bg[1];
|
||||||
@@ -72,7 +77,7 @@ function centeredTransform(img: PixelImage, forward: AffineMatrix): PixelImage {
|
|||||||
[0.5, 0.5],
|
[0.5, 0.5],
|
||||||
[img.width - 0.5, 0.5],
|
[img.width - 0.5, 0.5],
|
||||||
[0.5, img.height - 0.5],
|
[0.5, img.height - 0.5],
|
||||||
[img.width - 0.5, img.height - 0.5]
|
[img.width - 0.5, img.height - 0.5],
|
||||||
]) {
|
]) {
|
||||||
const qx = forward[0] * px + forward[2] * py + forward[4];
|
const qx = forward[0] * px + forward[2] * py + forward[4];
|
||||||
const qy = forward[1] * px + forward[3] * py + forward[5];
|
const qy = forward[1] * px + forward[3] * py + forward[5];
|
||||||
@@ -85,14 +90,23 @@ function centeredTransform(img: PixelImage, forward: AffineMatrix): PixelImage {
|
|||||||
const outH = Math.round(maxY - minY) + 1;
|
const outH = Math.round(maxY - minY) + 1;
|
||||||
const tx = inv[0] * minX + inv[2] * minY - 0.5;
|
const tx = inv[0] * minX + inv[2] * minY - 0.5;
|
||||||
const ty = inv[1] * minX + inv[3] * minY - 0.5;
|
const ty = inv[1] * minX + inv[3] * minY - 0.5;
|
||||||
return transformImage(img, [inv[0], inv[1], inv[2], inv[3], tx, ty], outW, outH);
|
return transformImage(
|
||||||
|
img,
|
||||||
|
[inv[0], inv[1], inv[2], inv[3], tx, ty],
|
||||||
|
outW,
|
||||||
|
outH,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function skewImage(img: PixelImage, degX: number, degY: number): PixelImage {
|
export function skewImage(
|
||||||
|
img: PixelImage,
|
||||||
|
degX: number,
|
||||||
|
degY: number,
|
||||||
|
): PixelImage {
|
||||||
const kx = Math.tan((degX * Math.PI) / 180);
|
const kx = Math.tan((degX * Math.PI) / 180);
|
||||||
const ky = Math.tan((degY * Math.PI) / 180);
|
const ky = Math.tan((degY * Math.PI) / 180);
|
||||||
if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
|
if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
|
||||||
throw new ToolError('errors.skewAngle');
|
throw new ToolError("errors.skewAngle");
|
||||||
}
|
}
|
||||||
return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
|
return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
colorMask,
|
colorMask,
|
||||||
extractAlphaMask,
|
extractAlphaMask,
|
||||||
@@ -8,53 +8,52 @@ import {
|
|||||||
parseHex,
|
parseHex,
|
||||||
removeColorToAlpha,
|
removeColorToAlpha,
|
||||||
roundCorners,
|
roundCorners,
|
||||||
setAlphaChannel
|
setAlphaChannel,
|
||||||
} from './alpha';
|
} from "./alpha";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe('hardenAlpha', () => {
|
describe("hardenAlpha", () => {
|
||||||
it('бинаризует альфу по порогу, RGB не трогает', () => {
|
it("бинаризует альфу по порогу, RGB не трогает", () => {
|
||||||
const out = hardenAlpha(
|
const out = hardenAlpha(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[10, 20, 30, 100],
|
[10, 20, 30, 100],
|
||||||
[40, 50, 60, 200]
|
[40, 50, 60, 200],
|
||||||
]),
|
]),
|
||||||
50
|
50,
|
||||||
);
|
);
|
||||||
expect([...out.data]).toEqual([
|
expect([...out.data]).toEqual([10, 20, 30, 0, 40, 50, 60, 255]);
|
||||||
10, 20, 30, 0,
|
|
||||||
40, 50, 60, 255
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setAlphaChannel', () => {
|
describe("setAlphaChannel", () => {
|
||||||
it('задаёт константную альфу', () => {
|
it("задаёт константную альфу", () => {
|
||||||
const out = setAlphaChannel(makeImage(2, 1, [
|
const out = setAlphaChannel(
|
||||||
[1, 2, 3, 255],
|
makeImage(2, 1, [
|
||||||
[4, 5, 6, 0]
|
[1, 2, 3, 255],
|
||||||
]), 50);
|
[4, 5, 6, 0],
|
||||||
|
]),
|
||||||
|
50,
|
||||||
|
);
|
||||||
expect(out.data[3]).toBe(128);
|
expect(out.data[3]).toBe(128);
|
||||||
expect(out.data[7]).toBe(128);
|
expect(out.data[7]).toBe(128);
|
||||||
expect(out.data[0]).toBe(1);
|
expect(out.data[0]).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractAlphaMask', () => {
|
describe("extractAlphaMask", () => {
|
||||||
it('переводит альфу в чёрно-белую непрозрачную маску', () => {
|
it("переводит альфу в чёрно-белую непрозрачную маску", () => {
|
||||||
const out = extractAlphaMask(makeImage(2, 1, [
|
const out = extractAlphaMask(
|
||||||
[10, 20, 30, 255],
|
makeImage(2, 1, [
|
||||||
[40, 50, 60, 0]
|
[10, 20, 30, 255],
|
||||||
]));
|
[40, 50, 60, 0],
|
||||||
expect([...out.data]).toEqual([
|
]),
|
||||||
255, 255, 255, 255,
|
);
|
||||||
0, 0, 0, 255
|
expect([...out.data]).toEqual([255, 255, 255, 255, 0, 0, 0, 255]);
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('roundCorners', () => {
|
describe("roundCorners", () => {
|
||||||
it('срезает углы, центр и середины сторон остаются', () => {
|
it("срезает углы, центр и середины сторон остаются", () => {
|
||||||
const img = makeImage(11, 11, new Array(121).fill([100, 100, 100, 255]));
|
const img = makeImage(11, 11, new Array(121).fill([100, 100, 100, 255]));
|
||||||
const out = roundCorners(img, 40);
|
const out = roundCorners(img, 40);
|
||||||
expect(out.data[(0 * 11 + 0) * 4 + 3]).toBe(0);
|
expect(out.data[(0 * 11 + 0) * 4 + 3]).toBe(0);
|
||||||
@@ -62,125 +61,125 @@ describe('roundCorners', () => {
|
|||||||
expect(out.data[(5 * 11 + 0) * 4 + 3]).toBe(255);
|
expect(out.data[(5 * 11 + 0) * 4 + 3]).toBe(255);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('нулевой радиус ничего не меняет', () => {
|
it("нулевой радиус ничего не меняет", () => {
|
||||||
const img = makeImage(2, 2, new Array(4).fill([1, 2, 3, 255]));
|
const img = makeImage(2, 2, new Array(4).fill([1, 2, 3, 255]));
|
||||||
expect([...roundCorners(img, 0).data]).toEqual([...img.data]);
|
expect([...roundCorners(img, 0).data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('invertAlpha', () => {
|
describe("invertAlpha", () => {
|
||||||
it('обращает альфу, RGB не трогает', () => {
|
it("обращает альфу, RGB не трогает", () => {
|
||||||
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
|
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
|
||||||
expect([...out.data]).toEqual([10, 20, 30, 127]);
|
expect([...out.data]).toEqual([10, 20, 30, 127]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('removeColorToAlpha', () => {
|
describe("removeColorToAlpha", () => {
|
||||||
const fixture = () =>
|
const fixture = () =>
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[255, 255, 255, 255],
|
[255, 255, 255, 255],
|
||||||
[255, 0, 0, 255]
|
[255, 0, 0, 255],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
it('обнуляет альфу только у точного совпадения при tolerance=0', () => {
|
it("обнуляет альфу только у точного совпадения при tolerance=0", () => {
|
||||||
const out = removeColorToAlpha(fixture(), '#ff0000', 0);
|
const out = removeColorToAlpha(fixture(), "#ff0000", 0);
|
||||||
expect(out.data[3]).toBe(255);
|
expect(out.data[3]).toBe(255);
|
||||||
expect(out.data[7]).toBe(0);
|
expect(out.data[7]).toBe(0);
|
||||||
expect(out.data[4]).toBe(255);
|
expect(out.data[4]).toBe(255);
|
||||||
expect(out.data[5]).toBe(0);
|
expect(out.data[5]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('поддерживает короткую форму и регистр hex', () => {
|
it("поддерживает короткую форму и регистр hex", () => {
|
||||||
expect([...removeColorToAlpha(fixture(), '#f00', 0).data.slice(4)]).toEqual([
|
expect([...removeColorToAlpha(fixture(), "#f00", 0).data.slice(4)]).toEqual(
|
||||||
255, 0, 0, 0
|
[255, 0, 0, 0],
|
||||||
]);
|
);
|
||||||
expect([...removeColorToAlpha(fixture(), '#FF0000', 0).data.slice(4)]).toEqual([
|
expect([
|
||||||
255, 0, 0, 0
|
...removeColorToAlpha(fixture(), "#FF0000", 0).data.slice(4),
|
||||||
]);
|
]).toEqual([255, 0, 0, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerance 100% удаляет весь диапазон расстояний', () => {
|
it("tolerance 100% удаляет весь диапазон расстояний", () => {
|
||||||
const out = removeColorToAlpha(fixture(), '#000000', 100);
|
const out = removeColorToAlpha(fixture(), "#000000", 100);
|
||||||
expect(out.data[3]).toBe(0);
|
expect(out.data[3]).toBe(0);
|
||||||
expect(out.data[7]).toBe(0);
|
expect(out.data[7]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('промежуточный tolerance различает близкие и далёкие цвета', () => {
|
it("промежуточный tolerance различает близкие и далёкие цвета", () => {
|
||||||
const img = makeImage(2, 1, [
|
const img = makeImage(2, 1, [
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[128, 128, 128, 255]
|
[128, 128, 128, 255],
|
||||||
]);
|
]);
|
||||||
const kept = removeColorToAlpha(img, '#000000', 40);
|
const kept = removeColorToAlpha(img, "#000000", 40);
|
||||||
const removed = removeColorToAlpha(img, '#000000', 60);
|
const removed = removeColorToAlpha(img, "#000000", 60);
|
||||||
expect(kept.data[3]).toBe(0);
|
expect(kept.data[3]).toBe(0);
|
||||||
expect(kept.data[7]).toBe(255);
|
expect(kept.data[7]).toBe(255);
|
||||||
expect(removed.data[3]).toBe(0);
|
expect(removed.data[3]).toBe(0);
|
||||||
expect(removed.data[7]).toBe(0);
|
expect(removed.data[7]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('не мутирует вход', () => {
|
it("не мутирует вход", () => {
|
||||||
const img = fixture();
|
const img = fixture();
|
||||||
removeColorToAlpha(img, '#ffffff', 100);
|
removeColorToAlpha(img, "#ffffff", 100);
|
||||||
expect([...img.data]).toEqual([
|
expect([...img.data]).toEqual([255, 255, 255, 255, 255, 0, 0, 255]);
|
||||||
255, 255, 255, 255,
|
|
||||||
255, 0, 0, 255
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('colorMask', () => {
|
describe("colorMask", () => {
|
||||||
it('удаляемые пиксели белые, остальные чёрные, маска непрозрачная', () => {
|
it("удаляемые пиксели белые, остальные чёрные, маска непрозрачная", () => {
|
||||||
const out = colorMask(
|
const out = colorMask(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[0, 255, 0, 255]
|
[0, 255, 0, 255],
|
||||||
]),
|
]),
|
||||||
'#ff0000',
|
"#ff0000",
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
expect([...out.data]).toEqual([
|
expect([...out.data]).toEqual([255, 255, 255, 255, 0, 0, 0, 255]);
|
||||||
255, 255, 255, 255,
|
|
||||||
0, 0, 0, 255
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('порог совпадает с removeColorToAlpha', () => {
|
it("порог совпадает с removeColorToAlpha", () => {
|
||||||
const img = makeImage(2, 1, [
|
const img = makeImage(2, 1, [
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[128, 128, 128, 255]
|
[128, 128, 128, 255],
|
||||||
]);
|
]);
|
||||||
const kept = colorMask(img, '#000000', 40);
|
const kept = colorMask(img, "#000000", 40);
|
||||||
const removed = colorMask(img, '#000000', 60);
|
const removed = colorMask(img, "#000000", 60);
|
||||||
expect(kept.data[4]).toBe(0);
|
expect(kept.data[4]).toBe(0);
|
||||||
expect(removed.data[4]).toBe(255);
|
expect(removed.data[4]).toBe(255);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('flattenOntoColor', () => {
|
describe("flattenOntoColor", () => {
|
||||||
it('непрозрачный пиксель не меняется, альфа становится 255', () => {
|
it("непрозрачный пиксель не меняется, альфа становится 255", () => {
|
||||||
const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 255]]), '#ffffff');
|
const out = flattenOntoColor(
|
||||||
|
makeImage(1, 1, [[10, 20, 30, 255]]),
|
||||||
|
"#ffffff",
|
||||||
|
);
|
||||||
expect([...out.data]).toEqual([10, 20, 30, 255]);
|
expect([...out.data]).toEqual([10, 20, 30, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('полностью прозрачный пиксель становится цветом подложки', () => {
|
it("полностью прозрачный пиксель становится цветом подложки", () => {
|
||||||
const out = flattenOntoColor(makeImage(1, 1, [[99, 99, 99, 0]]), '#ff8040');
|
const out = flattenOntoColor(makeImage(1, 1, [[99, 99, 99, 0]]), "#ff8040");
|
||||||
expect([...out.data]).toEqual([255, 128, 64, 255]);
|
expect([...out.data]).toEqual([255, 128, 64, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('полупрозрачный пиксель смешивается с подложкой', () => {
|
it("полупрозрачный пиксель смешивается с подложкой", () => {
|
||||||
const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 128]]), '#ffffff');
|
const out = flattenOntoColor(
|
||||||
|
makeImage(1, 1, [[10, 20, 30, 128]]),
|
||||||
|
"#ffffff",
|
||||||
|
);
|
||||||
expect([...out.data]).toEqual([132, 137, 142, 255]);
|
expect([...out.data]).toEqual([132, 137, 142, 255]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseHex', () => {
|
describe("parseHex", () => {
|
||||||
it('разбирает #rrggbb, rrggbb, #rgb', () => {
|
it("разбирает #rrggbb, rrggbb, #rgb", () => {
|
||||||
expect(parseHex('#ff8040')).toEqual([255, 128, 64]);
|
expect(parseHex("#ff8040")).toEqual([255, 128, 64]);
|
||||||
expect(parseHex('ff8040')).toEqual([255, 128, 64]);
|
expect(parseHex("ff8040")).toEqual([255, 128, 64]);
|
||||||
expect(parseHex('#F80')).toEqual([255, 136, 0]);
|
expect(parseHex("#F80")).toEqual([255, 136, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => {
|
it.each(["zzz", "12345", "##ff", ""])('бросает ошибку на "%s"', (bad) => {
|
||||||
expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
|
expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+42
-16
@@ -1,17 +1,22 @@
|
|||||||
import { createPixelImage, type PixelImage } from './types';
|
import { createPixelImage, type PixelImage } from "./types";
|
||||||
import { ToolError } from './errors';
|
import { ToolError } from "./errors";
|
||||||
|
|
||||||
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
|
||||||
|
|
||||||
export function removeColorToAlpha(
|
export function removeColorToAlpha(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
hex: string,
|
hex: string,
|
||||||
tolerancePercent = 0
|
tolerancePercent = 0,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const [targetR, targetG, targetB] = parseHex(hex);
|
const [targetR, targetG, targetB] = parseHex(hex);
|
||||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
const tolerance =
|
||||||
|
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
||||||
const thresholdSq = tolerance * tolerance;
|
const thresholdSq = tolerance * tolerance;
|
||||||
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
|
const out: PixelImage = {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
data: img.data.slice(),
|
||||||
|
};
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
const dr = out.data[i] - targetR;
|
const dr = out.data[i] - targetR;
|
||||||
const dg = out.data[i + 1] - targetG;
|
const dg = out.data[i + 1] - targetG;
|
||||||
@@ -25,7 +30,11 @@ export function removeColorToAlpha(
|
|||||||
|
|
||||||
export function setAlphaChannel(img: PixelImage, percent: number): PixelImage {
|
export function setAlphaChannel(img: PixelImage, percent: number): PixelImage {
|
||||||
const alpha = Math.round((clamp(percent, 0, 100) / 100) * 255);
|
const alpha = Math.round((clamp(percent, 0, 100) / 100) * 255);
|
||||||
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
|
const out: PixelImage = {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
data: img.data.slice(),
|
||||||
|
};
|
||||||
for (let i = 3; i < out.data.length; i += 4) {
|
for (let i = 3; i < out.data.length; i += 4) {
|
||||||
out.data[i] = alpha;
|
out.data[i] = alpha;
|
||||||
}
|
}
|
||||||
@@ -44,10 +53,19 @@ export function extractAlphaMask(img: PixelImage): PixelImage {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function roundCorners(img: PixelImage, radiusPercent: number): PixelImage {
|
export function roundCorners(
|
||||||
const radius = (clamp(radiusPercent, 0, 50) / 100) * (Math.min(img.width, img.height) / 2);
|
img: PixelImage,
|
||||||
if (radius < 1) return { width: img.width, height: img.height, data: img.data.slice() };
|
radiusPercent: number,
|
||||||
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
|
): PixelImage {
|
||||||
|
const radius =
|
||||||
|
(clamp(radiusPercent, 0, 50) / 100) * (Math.min(img.width, img.height) / 2);
|
||||||
|
if (radius < 1)
|
||||||
|
return { width: img.width, height: img.height, data: img.data.slice() };
|
||||||
|
const out: PixelImage = {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
data: img.data.slice(),
|
||||||
|
};
|
||||||
const r2 = radius * radius;
|
const r2 = radius * radius;
|
||||||
for (let y = 0; y < out.height; y++) {
|
for (let y = 0; y < out.height; y++) {
|
||||||
for (let x = 0; x < out.width; x++) {
|
for (let x = 0; x < out.width; x++) {
|
||||||
@@ -74,7 +92,10 @@ export function invertAlpha(img: PixelImage): PixelImage {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hardenAlpha(img: PixelImage, thresholdPercent: number): PixelImage {
|
export function hardenAlpha(
|
||||||
|
img: PixelImage,
|
||||||
|
thresholdPercent: number,
|
||||||
|
): PixelImage {
|
||||||
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
@@ -86,9 +107,14 @@ export function hardenAlpha(img: PixelImage, thresholdPercent: number): PixelIma
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage {
|
export function colorMask(
|
||||||
|
img: PixelImage,
|
||||||
|
hex: string,
|
||||||
|
tolerancePercent = 0,
|
||||||
|
): PixelImage {
|
||||||
const [targetR, targetG, targetB] = parseHex(hex);
|
const [targetR, targetG, targetB] = parseHex(hex);
|
||||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
const tolerance =
|
||||||
|
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
||||||
const thresholdSq = tolerance * tolerance;
|
const thresholdSq = tolerance * tolerance;
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
@@ -121,20 +147,20 @@ export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
|
|||||||
export function parseHex(hex: string): [number, number, number] {
|
export function parseHex(hex: string): [number, number, number] {
|
||||||
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new ToolError('errors.badHex', { value: hex });
|
throw new ToolError("errors.badHex", { value: hex });
|
||||||
}
|
}
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
if (digits.length === 3) {
|
if (digits.length === 3) {
|
||||||
return [
|
return [
|
||||||
parseInt(digits[0] + digits[0], 16),
|
parseInt(digits[0] + digits[0], 16),
|
||||||
parseInt(digits[1] + digits[1], 16),
|
parseInt(digits[1] + digits[1], 16),
|
||||||
parseInt(digits[2] + digits[2], 16)
|
parseInt(digits[2] + digits[2], 16),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
parseInt(digits.slice(0, 2), 16),
|
parseInt(digits.slice(0, 2), 16),
|
||||||
parseInt(digits.slice(2, 4), 16),
|
parseInt(digits.slice(2, 4), 16),
|
||||||
parseInt(digits.slice(4, 6), 16)
|
parseInt(digits.slice(4, 6), 16),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +1,87 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
hasTransparency,
|
hasTransparency,
|
||||||
imageInfo,
|
imageInfo,
|
||||||
isGrayscale,
|
isGrayscale,
|
||||||
orientationOf
|
orientationOf,
|
||||||
} from './analyze';
|
} from "./analyze";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe('imageInfo', () => {
|
describe("imageInfo", () => {
|
||||||
it('находит полупрозрачные пиксели и считает уникальные RGBA-цвета', () => {
|
it("находит полупрозрачные пиксели и считает уникальные RGBA-цвета", () => {
|
||||||
const info = imageInfo(
|
const info = imageInfo(
|
||||||
makeImage(2, 2, [
|
makeImage(2, 2, [
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[255, 0, 0, 128],
|
[255, 0, 0, 128],
|
||||||
[255, 0, 0, 128]
|
[255, 0, 0, 128],
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
expect(info).toEqual({ width: 2, height: 2, hasAlpha: true, colorCount: 2 });
|
expect(info).toEqual({
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
hasAlpha: true,
|
||||||
|
colorCount: 2,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('полностью непрозрачное изображение — hasAlpha false', () => {
|
it("полностью непрозрачное изображение — hasAlpha false", () => {
|
||||||
const info = imageInfo(
|
const info = imageInfo(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[10, 20, 30, 255],
|
[10, 20, 30, 255],
|
||||||
[40, 50, 60, 255]
|
[40, 50, 60, 255],
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
expect(info.hasAlpha).toBe(false);
|
expect(info.hasAlpha).toBe(false);
|
||||||
expect(info.colorCount).toBe(2);
|
expect(info.colorCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('разная альфа означает разные цвета', () => {
|
it("разная альфа означает разные цвета", () => {
|
||||||
const info = imageInfo(
|
const info = imageInfo(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[255, 0, 0, 128]
|
[255, 0, 0, 128],
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
expect(info.hasAlpha).toBe(true);
|
expect(info.hasAlpha).toBe(true);
|
||||||
expect(info.colorCount).toBe(2);
|
expect(info.colorCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('возвращает корректные размеры неквадрата', () => {
|
it("возвращает корректные размеры неквадрата", () => {
|
||||||
const info = imageInfo(makeImage(5, 3, new Array(15).fill([1, 2, 3, 4])));
|
const info = imageInfo(makeImage(5, 3, new Array(15).fill([1, 2, 3, 4])));
|
||||||
expect(info.width).toBe(5);
|
expect(info.width).toBe(5);
|
||||||
expect(info.height).toBe(3);
|
expect(info.height).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isGrayscale', () => {
|
describe("isGrayscale", () => {
|
||||||
it('серые пиксели — монохром', () => {
|
it("серые пиксели — монохром", () => {
|
||||||
expect(isGrayscale(makeImage(1, 1, [[10, 10, 10, 255]]))).toBe(true);
|
expect(isGrayscale(makeImage(1, 1, [[10, 10, 10, 255]]))).toBe(true);
|
||||||
});
|
});
|
||||||
it('цветной пиксель ломает монохром', () => {
|
it("цветной пиксель ломает монохром", () => {
|
||||||
expect(isGrayscale(makeImage(1, 1, [[10, 11, 10, 255]]))).toBe(false);
|
expect(isGrayscale(makeImage(1, 1, [[10, 11, 10, 255]]))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hasTransparency', () => {
|
describe("hasTransparency", () => {
|
||||||
it('альфа ниже 255 — прозрачность есть', () => {
|
it("альфа ниже 255 — прозрачность есть", () => {
|
||||||
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 254]]))).toBe(true);
|
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 254]]))).toBe(true);
|
||||||
});
|
});
|
||||||
it('все пиксели непрозрачны', () => {
|
it("все пиксели непрозрачны", () => {
|
||||||
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 255]]))).toBe(false);
|
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 255]]))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('orientationOf', () => {
|
describe("orientationOf", () => {
|
||||||
it('определяет ориентацию', () => {
|
it("определяет ориентацию", () => {
|
||||||
expect(orientationOf(makeImage(2, 3, new Array(6).fill([1, 1, 1, 1])))).toBe('portrait');
|
expect(
|
||||||
expect(orientationOf(makeImage(3, 2, new Array(6).fill([1, 1, 1, 1])))).toBe('landscape');
|
orientationOf(makeImage(2, 3, new Array(6).fill([1, 1, 1, 1]))),
|
||||||
expect(orientationOf(makeImage(2, 2, new Array(4).fill([1, 1, 1, 1])))).toBe('square');
|
).toBe("portrait");
|
||||||
|
expect(
|
||||||
|
orientationOf(makeImage(3, 2, new Array(6).fill([1, 1, 1, 1]))),
|
||||||
|
).toBe("landscape");
|
||||||
|
expect(
|
||||||
|
orientationOf(makeImage(2, 2, new Array(4).fill([1, 1, 1, 1]))),
|
||||||
|
).toBe("square");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from "./types";
|
||||||
|
|
||||||
export type ImageInfo = {
|
export type ImageInfo = {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -22,12 +22,20 @@ export function imageInfo(img: PixelImage): ImageInfo {
|
|||||||
0;
|
0;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
}
|
}
|
||||||
return { width: img.width, height: img.height, hasAlpha, colorCount: seen.size };
|
return {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
hasAlpha,
|
||||||
|
colorCount: seen.size,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGrayscale(img: PixelImage): boolean {
|
export function isGrayscale(img: PixelImage): boolean {
|
||||||
for (let i = 0; i < img.data.length; i += 4) {
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
if (img.data[i] !== img.data[i + 1] || img.data[i + 1] !== img.data[i + 2]) {
|
if (
|
||||||
|
img.data[i] !== img.data[i + 1] ||
|
||||||
|
img.data[i + 1] !== img.data[i + 2]
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,10 +49,10 @@ export function hasTransparency(img: PixelImage): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Orientation = 'portrait' | 'landscape' | 'square';
|
export type Orientation = "portrait" | "landscape" | "square";
|
||||||
|
|
||||||
export function orientationOf(img: PixelImage): Orientation {
|
export function orientationOf(img: PixelImage): Orientation {
|
||||||
if (img.height > img.width) return 'portrait';
|
if (img.height > img.width) return "portrait";
|
||||||
if (img.width > img.height) return 'landscape';
|
if (img.width > img.height) return "landscape";
|
||||||
return 'square';
|
return "square";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +1,119 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
backgroundMaskPreview,
|
backgroundMaskPreview,
|
||||||
backgroundRemovalMask,
|
backgroundRemovalMask,
|
||||||
removeBackground
|
removeBackground,
|
||||||
} from './background';
|
} from "./background";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
const GREEN = [0, 255, 0, 255];
|
const GREEN = [0, 255, 0, 255];
|
||||||
const RED = [255, 0, 0, 255];
|
const RED = [255, 0, 0, 255];
|
||||||
|
|
||||||
describe('backgroundRemovalMask', () => {
|
describe("backgroundRemovalMask", () => {
|
||||||
it('глобальный режим удаляет все совпадающие пиксели', () => {
|
it("глобальный режим удаляет все совпадающие пиксели", () => {
|
||||||
const mask = backgroundRemovalMask(
|
const mask = backgroundRemovalMask(makeImage(2, 1, [GREEN, RED]), {
|
||||||
makeImage(2, 1, [GREEN, RED]),
|
color: "#00ff00",
|
||||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
tolerancePercent: 0,
|
||||||
);
|
outerOnly: false,
|
||||||
|
smoothPasses: 0,
|
||||||
|
});
|
||||||
expect([...mask]).toEqual([1, 0]);
|
expect([...mask]).toEqual([1, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('режим внешних областей', () => {
|
describe("режим внешних областей", () => {
|
||||||
const ringRedCenterGreen = [
|
const ringRedCenterGreen = [RED, RED, RED, RED, GREEN, RED, RED, RED, RED];
|
||||||
RED, RED, RED,
|
|
||||||
RED, GREEN, RED,
|
|
||||||
RED, RED, RED
|
|
||||||
];
|
|
||||||
|
|
||||||
it('заливка от краёв не достаёт до изолированного совпадающего острова', () => {
|
it("заливка от краёв не достаёт до изолированного совпадающего острова", () => {
|
||||||
const mask = backgroundRemovalMask(
|
const mask = backgroundRemovalMask(makeImage(3, 3, ringRedCenterGreen), {
|
||||||
makeImage(3, 3, ringRedCenterGreen),
|
color: "#00ff00",
|
||||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: true, smoothPasses: 0 }
|
tolerancePercent: 0,
|
||||||
);
|
outerOnly: true,
|
||||||
expect(mask[4]).toBe(0);
|
smoothPasses: 0,
|
||||||
|
});
|
||||||
|
expect(mask[4]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("глобальный режим удаляет и изолированный остров", () => {
|
||||||
|
const mask = backgroundRemovalMask(makeImage(3, 3, ringRedCenterGreen), {
|
||||||
|
color: "#00ff00",
|
||||||
|
tolerancePercent: 0,
|
||||||
|
outerOnly: false,
|
||||||
|
smoothPasses: 0,
|
||||||
|
});
|
||||||
|
expect(mask[4]).toBe(1);
|
||||||
|
expect(mask[0]).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('глобальный режим удаляет и изолированный остров', () => {
|
it("допуск расширяет захват по цветовому расстоянию", () => {
|
||||||
const mask = backgroundRemovalMask(
|
|
||||||
makeImage(3, 3, ringRedCenterGreen),
|
|
||||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
|
||||||
);
|
|
||||||
expect(mask[4]).toBe(1);
|
|
||||||
expect(mask[0]).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('допуск расширяет захват по цветовому расстоянию', () => {
|
|
||||||
const img = makeImage(2, 1, [
|
const img = makeImage(2, 1, [
|
||||||
[10, 10, 10, 255],
|
[10, 10, 10, 255],
|
||||||
[128, 128, 128, 255]
|
[128, 128, 128, 255],
|
||||||
]);
|
]);
|
||||||
const tight = backgroundRemovalMask(img, {
|
const tight = backgroundRemovalMask(img, {
|
||||||
color: '#000000', tolerancePercent: 40, outerOnly: false, smoothPasses: 0
|
color: "#000000",
|
||||||
|
tolerancePercent: 40,
|
||||||
|
outerOnly: false,
|
||||||
|
smoothPasses: 0,
|
||||||
});
|
});
|
||||||
const wide = backgroundRemovalMask(img, {
|
const wide = backgroundRemovalMask(img, {
|
||||||
color: '#000000', tolerancePercent: 60, outerOnly: false, smoothPasses: 0
|
color: "#000000",
|
||||||
|
tolerancePercent: 60,
|
||||||
|
outerOnly: false,
|
||||||
|
smoothPasses: 0,
|
||||||
});
|
});
|
||||||
expect(tight[1]).toBe(0);
|
expect(tight[1]).toBe(0);
|
||||||
expect(wide[1]).toBe(1);
|
expect(wide[1]).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('smoothMask-поведение через backgroundRemovalMask', () => {
|
describe("smoothMask-поведение через backgroundRemovalMask", () => {
|
||||||
const ringRedCenterGreen = [
|
const ringRedCenterGreen = [RED, RED, RED, RED, GREEN, RED, RED, RED, RED];
|
||||||
RED, RED, RED,
|
const opts = { color: "#00ff00", tolerancePercent: 0, outerOnly: false };
|
||||||
RED, GREEN, RED,
|
|
||||||
RED, RED, RED
|
|
||||||
];
|
|
||||||
const opts = { color: '#00ff00', tolerancePercent: 0, outerOnly: false };
|
|
||||||
|
|
||||||
const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19];
|
const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19];
|
||||||
|
|
||||||
it('без сглаживания центр удалён', () => {
|
it("без сглаживания центр удалён", () => {
|
||||||
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 0 });
|
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), {
|
||||||
|
...opts,
|
||||||
|
smoothPasses: 0,
|
||||||
|
});
|
||||||
expect(alphaAtCenter(img)).toBe(0);
|
expect(alphaAtCenter(img)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('два прохода мажоритарного фильтра возвращают изолированный пиксель', () => {
|
it("два прохода мажоритарного фильтра возвращают изолированный пиксель", () => {
|
||||||
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 2 });
|
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), {
|
||||||
|
...opts,
|
||||||
|
smoothPasses: 2,
|
||||||
|
});
|
||||||
expect(alphaAtCenter(img)).toBe(255);
|
expect(alphaAtCenter(img)).toBe(255);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('removeBackground', () => {
|
describe("removeBackground", () => {
|
||||||
it('обнуляет альфу удалённых, сохраняет RGB остальных', () => {
|
it("обнуляет альфу удалённых, сохраняет RGB остальных", () => {
|
||||||
const out = removeBackground(
|
const out = removeBackground(makeImage(2, 1, [GREEN, [5, 6, 7, 200]]), {
|
||||||
makeImage(2, 1, [GREEN, [5, 6, 7, 200]]),
|
color: "#00ff00",
|
||||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
tolerancePercent: 0,
|
||||||
);
|
outerOnly: false,
|
||||||
|
smoothPasses: 0,
|
||||||
|
});
|
||||||
expect(out.data[3]).toBe(0);
|
expect(out.data[3]).toBe(0);
|
||||||
expect([...out.data.slice(4, 8)]).toEqual([5, 6, 7, 200]);
|
expect([...out.data.slice(4, 8)]).toEqual([5, 6, 7, 200]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('backgroundMaskPreview', () => {
|
describe("backgroundMaskPreview", () => {
|
||||||
it('белое там, где удаление, чёрное — где остаёмся, всё непрозрачно', () => {
|
it("белое там, где удаление, чёрное — где остаёмся, всё непрозрачно", () => {
|
||||||
const preview = backgroundMaskPreview(
|
const preview = backgroundMaskPreview(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [GREEN, [9, 9, 9, 60]]),
|
||||||
GREEN,
|
{
|
||||||
[9, 9, 9, 60]
|
color: "#00ff00",
|
||||||
]),
|
tolerancePercent: 0,
|
||||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
outerOnly: false,
|
||||||
|
smoothPasses: 0,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect([...preview.data]).toEqual([
|
expect([...preview.data]).toEqual([255, 255, 255, 255, 0, 0, 0, 255]);
|
||||||
255, 255, 255, 255,
|
|
||||||
0, 0, 0, 255
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { parseHex } from './alpha';
|
import { parseHex } from "./alpha";
|
||||||
import { createPixelImage, type PixelImage } from './types';
|
import { createPixelImage, type PixelImage } from "./types";
|
||||||
|
|
||||||
export type BackgroundOptions = {
|
export type BackgroundOptions = {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -13,9 +13,10 @@ function buildRawMask(
|
|||||||
targetR: number,
|
targetR: number,
|
||||||
targetG: number,
|
targetG: number,
|
||||||
targetB: number,
|
targetB: number,
|
||||||
tolerancePercent: number
|
tolerancePercent: number,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * Math.sqrt(3 * 255 * 255);
|
const tolerance =
|
||||||
|
(clamp(tolerancePercent, 0, 100) / 100) * Math.sqrt(3 * 255 * 255);
|
||||||
const thresholdSq = tolerance * tolerance;
|
const thresholdSq = tolerance * tolerance;
|
||||||
const mask = new Uint8Array(img.width * img.height);
|
const mask = new Uint8Array(img.width * img.height);
|
||||||
for (let i = 0; i < mask.length; i++) {
|
for (let i = 0; i < mask.length; i++) {
|
||||||
@@ -61,7 +62,7 @@ export function smoothMask(
|
|||||||
mask: Uint8Array,
|
mask: Uint8Array,
|
||||||
w: number,
|
w: number,
|
||||||
h: number,
|
h: number,
|
||||||
passes: number
|
passes: number,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
let current = mask;
|
let current = mask;
|
||||||
const count = clamp(Math.trunc(passes), 0, 8);
|
const count = clamp(Math.trunc(passes), 0, 8);
|
||||||
@@ -89,7 +90,7 @@ export function smoothMask(
|
|||||||
|
|
||||||
export function backgroundRemovalMask(
|
export function backgroundRemovalMask(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
options: BackgroundOptions
|
options: BackgroundOptions,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const [tr, tg, tb] = parseHex(options.color);
|
const [tr, tg, tb] = parseHex(options.color);
|
||||||
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
|
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
|
||||||
@@ -99,7 +100,10 @@ export function backgroundRemovalMask(
|
|||||||
return smoothMask(mask, img.width, img.height, options.smoothPasses);
|
return smoothMask(mask, img.width, img.height, options.smoothPasses);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeBackground(img: PixelImage, options: BackgroundOptions): PixelImage {
|
export function removeBackground(
|
||||||
|
img: PixelImage,
|
||||||
|
options: BackgroundOptions,
|
||||||
|
): PixelImage {
|
||||||
const mask = backgroundRemovalMask(img, options);
|
const mask = backgroundRemovalMask(img, options);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < mask.length; i++) {
|
for (let i = 0; i < mask.length; i++) {
|
||||||
@@ -112,7 +116,10 @@ export function removeBackground(img: PixelImage, options: BackgroundOptions): P
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function backgroundMaskPreview(img: PixelImage, options: BackgroundOptions): PixelImage {
|
export function backgroundMaskPreview(
|
||||||
|
img: PixelImage,
|
||||||
|
options: BackgroundOptions,
|
||||||
|
): PixelImage {
|
||||||
const mask = backgroundRemovalMask(img, options);
|
const mask = backgroundRemovalMask(img, options);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < mask.length; i++) {
|
for (let i = 0; i < mask.length; i++) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { encodeBmpBytes } from './bmp';
|
import { encodeBmpBytes } from "./bmp";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe('encodeBmpBytes', () => {
|
describe("encodeBmpBytes", () => {
|
||||||
it('пишет заголовки BM и размеры для 2x1 без паддинга-неопределённости', () => {
|
it("пишет заголовки BM и размеры для 2x1 без паддинга-неопределённости", () => {
|
||||||
const bytes = encodeBmpBytes(
|
const bytes = encodeBmpBytes(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[0, 255, 0, 255]
|
[0, 255, 0, 255],
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
expect([bytes[0], bytes[1]]).toEqual([0x42, 0x4d]);
|
expect([bytes[0], bytes[1]]).toEqual([0x42, 0x4d]);
|
||||||
const view = new DataView(bytes.buffer);
|
const view = new DataView(bytes.buffer);
|
||||||
@@ -18,22 +18,19 @@ describe('encodeBmpBytes', () => {
|
|||||||
expect(view.getUint16(28, true)).toBe(24);
|
expect(view.getUint16(28, true)).toBe(24);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('хранит пиксели в BGR снизу-вверх с паддингом строки', () => {
|
it("хранит пиксели в BGR снизу-вверх с паддингом строки", () => {
|
||||||
const bytes = encodeBmpBytes(
|
const bytes = encodeBmpBytes(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[0, 255, 0, 255]
|
[0, 255, 0, 255],
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
expect([...bytes.slice(54, 60)]).toEqual([
|
expect([...bytes.slice(54, 60)]).toEqual([0, 0, 255, 0, 255, 0]);
|
||||||
0, 0, 255,
|
|
||||||
0, 255, 0
|
|
||||||
]);
|
|
||||||
expect(bytes[60]).toBe(0);
|
expect(bytes[60]).toBe(0);
|
||||||
expect(bytes[61]).toBe(0);
|
expect(bytes[61]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('нижняя строка изображения идёт первой в файле', () => {
|
it("нижняя строка изображения идёт первой в файле", () => {
|
||||||
const bytes = encodeBmpBytes(
|
const bytes = encodeBmpBytes(
|
||||||
makeImage(4, 2, [
|
makeImage(4, 2, [
|
||||||
[10, 10, 10, 255],
|
[10, 10, 10, 255],
|
||||||
@@ -43,8 +40,8 @@ describe('encodeBmpBytes', () => {
|
|||||||
[50, 50, 50, 255],
|
[50, 50, 50, 255],
|
||||||
[60, 60, 60, 255],
|
[60, 60, 60, 255],
|
||||||
[70, 70, 70, 255],
|
[70, 70, 70, 255],
|
||||||
[80, 80, 80, 255]
|
[80, 80, 80, 255],
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
expect(bytes[54]).toBe(50);
|
expect(bytes[54]).toBe(50);
|
||||||
expect(bytes[54 + 12]).toBe(10);
|
expect(bytes[54 + 12]).toBe(10);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from "./types";
|
||||||
|
|
||||||
export function encodeBmpBytes(img: PixelImage): Uint8Array<ArrayBuffer> {
|
export function encodeBmpBytes(img: PixelImage): Uint8Array<ArrayBuffer> {
|
||||||
const rowSize = Math.ceil((img.width * 3) / 4) * 4;
|
const rowSize = Math.ceil((img.width * 3) / 4) * 4;
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { hexToRgb } from './palette';
|
import { hexToRgb } from "./palette";
|
||||||
import { renderSpace, SPACES } from './channels';
|
import { renderSpace, SPACES } from "./channels";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe('преобразования пространств', () => {
|
describe("преобразования пространств", () => {
|
||||||
it('hsl красного: h=0, s=1, l=0.5', () => {
|
it("hsl красного: h=0, s=1, l=0.5", () => {
|
||||||
const [h, s, l] = SPACES.hsl.convert(hexToRgb('#ff0000'));
|
const [h, s, l] = SPACES.hsl.convert(hexToRgb("#ff0000"));
|
||||||
expect(h).toBeCloseTo(0);
|
expect(h).toBeCloseTo(0);
|
||||||
expect(s).toBeCloseTo(1);
|
expect(s).toBeCloseTo(1);
|
||||||
expect(l).toBeCloseTo(0.5);
|
expect(l).toBeCloseTo(0.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hsv белого: v=1, чёрного: v=0', () => {
|
it("hsv белого: v=1, чёрного: v=0", () => {
|
||||||
expect(SPACES.hsv.convert({ r: 255, g: 255, b: 255 })[2]).toBeCloseTo(1);
|
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);
|
expect(SPACES.hsv.convert({ r: 0, g: 0, b: 0 })[2]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hsi серого: s=0', () => {
|
it("hsi серого: s=0", () => {
|
||||||
const [, s] = SPACES.hsi.convert({ r: 128, g: 128, b: 128 });
|
const [, s] = SPACES.hsi.convert({ r: 128, g: 128, b: 128 });
|
||||||
expect(s).toBeCloseTo(0);
|
expect(s).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cmyk: белый — 0,0,0,0; чёрный — 0,0,0,1', () => {
|
it("cmyk: белый — 0,0,0,0; чёрный — 0,0,0,1", () => {
|
||||||
const w = SPACES.cmyk.convert({ r: 255, g: 255, b: 255 });
|
const w = SPACES.cmyk.convert({ r: 255, g: 255, b: 255 });
|
||||||
const b = SPACES.cmyk.convert({ r: 0, g: 0, b: 0 });
|
const b = SPACES.cmyk.convert({ r: 0, g: 0, b: 0 });
|
||||||
expect(w.every((v) => Math.abs(v) < 1e-9)).toBe(true);
|
expect(w.every((v) => Math.abs(v) < 1e-9)).toBe(true);
|
||||||
expect(b[3]).toBeCloseTo(1);
|
expect(b[3]).toBeCloseTo(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ycbcr белого: y≈1, cb≈cr≈0.5', () => {
|
it("ycbcr белого: y≈1, cb≈cr≈0.5", () => {
|
||||||
const [y, cb, cr] = SPACES.ycbcr.convert({ r: 255, g: 255, b: 255 });
|
const [y, cb, cr] = SPACES.ycbcr.convert({ r: 255, g: 255, b: 255 });
|
||||||
expect(y).toBeCloseTo(1);
|
expect(y).toBeCloseTo(1);
|
||||||
expect(cb).toBeCloseTo(0.5, 2);
|
expect(cb).toBeCloseTo(0.5, 2);
|
||||||
expect(cr).toBeCloseTo(0.5, 2);
|
expect(cr).toBeCloseTo(0.5, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lab белого: l≈1, a≈b≈0.5 (центр)', () => {
|
it("lab белого: l≈1, a≈b≈0.5 (центр)", () => {
|
||||||
const [l, a, bb] = SPACES.lab.convert({ r: 255, g: 255, b: 255 });
|
const [l, a, bb] = SPACES.lab.convert({ r: 255, g: 255, b: 255 });
|
||||||
expect(l).toBeCloseTo(1, 2);
|
expect(l).toBeCloseTo(1, 2);
|
||||||
expect(a).toBeCloseTo(0.5, 2);
|
expect(a).toBeCloseTo(0.5, 2);
|
||||||
@@ -43,23 +43,25 @@ describe('преобразования пространств', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renderSpace', () => {
|
describe("renderSpace", () => {
|
||||||
const red = makeImage(1, 1, [[255, 0, 0, 255]]);
|
const red = makeImage(1, 1, [[255, 0, 0, 255]]);
|
||||||
|
|
||||||
it('gray-режим: компонент y (жёлтый) чистого красного = белый', () => {
|
it("gray-режим: компонент y (жёлтый) чистого красного = белый", () => {
|
||||||
const out = renderSpace(red, 'cmyk', 'y', 'gray');
|
const out = renderSpace(red, "cmyk", "y", "gray");
|
||||||
expect([...out.data]).toEqual([255, 255, 255, 255]);
|
expect([...out.data]).toEqual([255, 255, 255, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('color-режим hsl красного: каналы = тон/насыщенность/светлота', () => {
|
it("color-режим hsl красного: каналы = тон/насыщенность/светлота", () => {
|
||||||
const out = renderSpace(red, 'hsl', 's', 'color');
|
const out = renderSpace(red, "hsl", "s", "color");
|
||||||
expect(out.data[0]).toBe(0);
|
expect(out.data[0]).toBe(0);
|
||||||
expect(out.data[1]).toBe(255);
|
expect(out.data[1]).toBe(255);
|
||||||
expect(out.data[2]).toBeGreaterThanOrEqual(127);
|
expect(out.data[2]).toBeGreaterThanOrEqual(127);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('неизвестное пространство или компонент дают прозрачную заглушку', () => {
|
it("неизвестное пространство или компонент дают прозрачную заглушку", () => {
|
||||||
const junk = makeImage(1, 1, [[10, 20, 30, 255]]);
|
const junk = makeImage(1, 1, [[10, 20, 30, 255]]);
|
||||||
expect(renderSpace(junk, 'cmyk' as never, 'zz' as never, 'gray').data[3]).toBe(0);
|
expect(
|
||||||
|
renderSpace(junk, "cmyk" as never, "zz" as never, "gray").data[3],
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from "./types";
|
||||||
import { createPixelImage } from './types';
|
import { createPixelImage } from "./types";
|
||||||
import { rgbToHsl } from './palette';
|
import { rgbToHsl } from "./palette";
|
||||||
import type { Rgb } from './palette';
|
import type { Rgb } from "./palette";
|
||||||
|
|
||||||
/** Все компоненты нормализованы в 0..1 в порядке объявления. */
|
/** Все компоненты нормализованы в 0..1 в порядке объявления. */
|
||||||
export type SpaceComponents = number[];
|
export type SpaceComponents = number[];
|
||||||
|
|
||||||
export type SpaceId = 'hsl' | 'hsv' | 'hsi' | 'cmyk' | 'ycbcr' | 'lab';
|
export type SpaceId = "hsl" | "hsv" | "hsi" | "cmyk" | "ycbcr" | "lab";
|
||||||
|
|
||||||
function hueOf({ r, g, b }: Rgb): number {
|
function hueOf({ r, g, b }: Rgb): number {
|
||||||
const max = Math.max(r, g, b);
|
const max = Math.max(r, g, b);
|
||||||
@@ -47,7 +47,12 @@ function rgbToCmyk({ r, g, b }: Rgb): [number, number, number, number] {
|
|||||||
const bn = b / 255;
|
const bn = b / 255;
|
||||||
const k = 1 - Math.max(rn, gn, bn);
|
const k = 1 - Math.max(rn, gn, bn);
|
||||||
if (k === 1) return [0, 0, 0, 1];
|
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];
|
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] {
|
function rgbToYcbcr({ r, g, b }: Rgb): [number, number, number] {
|
||||||
@@ -71,27 +76,34 @@ function rgbToLab({ r, g, b }: Rgb): [number, number, number] {
|
|||||||
const fy = f(y);
|
const fy = f(y);
|
||||||
const fz = f(z);
|
const fz = f(z);
|
||||||
// L нормирован 0..1; a/b центрированы на 0.5 с размахом ±0.5
|
// 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];
|
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 };
|
type SpaceDef = {
|
||||||
|
components: string[];
|
||||||
|
convert: (rgb: Rgb) => SpaceComponents;
|
||||||
|
};
|
||||||
|
|
||||||
export const SPACES: Record<SpaceId, SpaceDef> = {
|
export const SPACES: Record<SpaceId, SpaceDef> = {
|
||||||
hsl: {
|
hsl: {
|
||||||
components: ['h', 's', 'l'],
|
components: ["h", "s", "l"],
|
||||||
convert: ({ r, g, b }) => {
|
convert: ({ r, g, b }) => {
|
||||||
const { h, s, l } = rgbToHsl({ r, g, b });
|
const { h, s, l } = rgbToHsl({ r, g, b });
|
||||||
return [h / 360, s, l];
|
return [h / 360, s, l];
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
hsv: { components: ['h', 's', 'v'], convert: rgbToHsv },
|
hsv: { components: ["h", "s", "v"], convert: rgbToHsv },
|
||||||
hsi: { components: ['h', 's', 'i'], convert: rgbToHsi },
|
hsi: { components: ["h", "s", "i"], convert: rgbToHsi },
|
||||||
cmyk: { components: ['c', 'm', 'y', 'k'], convert: rgbToCmyk },
|
cmyk: { components: ["c", "m", "y", "k"], convert: rgbToCmyk },
|
||||||
ycbcr: { components: ['y', 'cb', 'cr'], convert: rgbToYcbcr },
|
ycbcr: { components: ["y", "cb", "cr"], convert: rgbToYcbcr },
|
||||||
lab: { components: ['l', 'a', 'b'], convert: rgbToLab }
|
lab: { components: ["l", "a", "b"], convert: rgbToLab },
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelDisplay = 'gray' | 'color';
|
export type ChannelDisplay = "gray" | "color";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Визуализация выбранного пространства: каждый компонент пространства
|
* Визуализация выбранного пространства: каждый компонент пространства
|
||||||
@@ -102,7 +114,7 @@ export function renderSpace(
|
|||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
space: SpaceId,
|
space: SpaceId,
|
||||||
component: string,
|
component: string,
|
||||||
display: ChannelDisplay
|
display: ChannelDisplay,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const def = SPACES[space];
|
const def = SPACES[space];
|
||||||
if (!def) return createPixelImage(img.width, img.height);
|
if (!def) return createPixelImage(img.width, img.height);
|
||||||
@@ -110,10 +122,14 @@ export function renderSpace(
|
|||||||
if (idx < 0) return createPixelImage(img.width, img.height);
|
if (idx < 0) return createPixelImage(img.width, img.height);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < img.data.length; i += 4) {
|
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 comps = def.convert({
|
||||||
|
r: img.data[i],
|
||||||
|
g: img.data[i + 1],
|
||||||
|
b: img.data[i + 2],
|
||||||
|
});
|
||||||
const di = i;
|
const di = i;
|
||||||
out.data[di + 3] = img.data[i + 3];
|
out.data[di + 3] = img.data[i + 3];
|
||||||
if (display === 'gray') {
|
if (display === "gray") {
|
||||||
const v = comps[idx] * 255;
|
const v = comps[idx] * 255;
|
||||||
out.data[di] = v;
|
out.data[di] = v;
|
||||||
out.data[di + 1] = v;
|
out.data[di + 1] = v;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
autoContrast,
|
autoContrast,
|
||||||
brightnessContrast,
|
brightnessContrast,
|
||||||
@@ -15,39 +15,43 @@ import {
|
|||||||
temperature,
|
temperature,
|
||||||
tint,
|
tint,
|
||||||
thresholdBlackWhite,
|
thresholdBlackWhite,
|
||||||
twoColors
|
twoColors,
|
||||||
} from './color';
|
} from "./color";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe('rgbToHex', () => {
|
describe("rgbToHex", () => {
|
||||||
it('форматирует базовые цвета', () => {
|
it("форматирует базовые цвета", () => {
|
||||||
expect(rgbToHex(255, 0, 0)).toBe('#ff0000');
|
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||||
expect(rgbToHex(1, 2, 3)).toBe('#010203');
|
expect(rgbToHex(1, 2, 3)).toBe("#010203");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('округляет дробные значения и клампит диапазон', () => {
|
it("округляет дробные значения и клампит диапазон", () => {
|
||||||
expect(rgbToHex(127.6, -5, 300)).toBe('#8000ff');
|
expect(rgbToHex(127.6, -5, 300)).toBe("#8000ff");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setOpacity', () => {
|
describe("setOpacity", () => {
|
||||||
it('умножает альфу на процент, RGB не трогает', () => {
|
it("умножает альфу на процент, RGB не трогает", () => {
|
||||||
const out = setOpacity(makeImage(1, 1, [[10, 20, 30, 128]]), 50);
|
const out = setOpacity(makeImage(1, 1, [[10, 20, 30, 128]]), 50);
|
||||||
expect([...out.data]).toEqual([10, 20, 30, 64]);
|
expect([...out.data]).toEqual([10, 20, 30, 64]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('100% не меняет, 0% делает полностью прозрачным', () => {
|
it("100% не меняет, 0% делает полностью прозрачным", () => {
|
||||||
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 100).data[3]).toBe(200);
|
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 100).data[3]).toBe(
|
||||||
|
200,
|
||||||
|
);
|
||||||
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 0).data[3]).toBe(0);
|
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 0).data[3]).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sepia', () => {
|
describe("sepia", () => {
|
||||||
it('применяет классическую матрицу с клампом', () => {
|
it("применяет классическую матрицу с клампом", () => {
|
||||||
const out = sepia(makeImage(2, 1, [
|
const out = sepia(
|
||||||
[255, 0, 0, 255],
|
makeImage(2, 1, [
|
||||||
[255, 255, 255, 255]
|
[255, 0, 0, 255],
|
||||||
]));
|
[255, 255, 255, 255],
|
||||||
|
]),
|
||||||
|
);
|
||||||
const px = (n: number) => [...out.data.slice(n * 4, n * 4 + 4)];
|
const px = (n: number) => [...out.data.slice(n * 4, n * 4 + 4)];
|
||||||
expect(px(0)).toEqual([100, 89, 69, 255]);
|
expect(px(0)).toEqual([100, 89, 69, 255]);
|
||||||
expect(px(1)[0]).toBe(255);
|
expect(px(1)[0]).toBe(255);
|
||||||
@@ -55,81 +59,85 @@ describe('sepia', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('changeHue', () => {
|
describe("changeHue", () => {
|
||||||
it('чистый красный при +120° становится чистым зелёным', () => {
|
it("чистый красный при +120° становится чистым зелёным", () => {
|
||||||
const out = changeHue(makeImage(1, 1, [[255, 0, 0, 255]]), 120);
|
const out = changeHue(makeImage(1, 1, [[255, 0, 0, 255]]), 120);
|
||||||
expect([...out.data]).toEqual([0, 255, 0, 255]);
|
expect([...out.data]).toEqual([0, 255, 0, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('сдвиг 360° возвращает исходные цвета', () => {
|
it("сдвиг 360° возвращает исходные цвета", () => {
|
||||||
const img = makeImage(1, 1, [[90, 140, 210, 255]]);
|
const img = makeImage(1, 1, [[90, 140, 210, 255]]);
|
||||||
expect([...changeHue(img, 360).data]).toEqual([...img.data]);
|
expect([...changeHue(img, 360).data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractChannel', () => {
|
describe("extractChannel", () => {
|
||||||
it('выдаёт выбранный канал оттенками серого', () => {
|
it("выдаёт выбранный канал оттенками серого", () => {
|
||||||
const out = extractChannel(makeImage(1, 1, [[10, 20, 30, 40]]), 'green');
|
const out = extractChannel(makeImage(1, 1, [[10, 20, 30, 40]]), "green");
|
||||||
expect([...out.data]).toEqual([20, 20, 20, 40]);
|
expect([...out.data]).toEqual([20, 20, 20, 40]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('swapChannels', () => {
|
describe("swapChannels", () => {
|
||||||
it('переставляет каналы парами', () => {
|
it("переставляет каналы парами", () => {
|
||||||
expect([...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), 'r-b').data]).toEqual([
|
expect([
|
||||||
30, 20, 10, 40
|
...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), "r-b").data,
|
||||||
]);
|
]).toEqual([30, 20, 10, 40]);
|
||||||
expect([...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), 'g-b').data]).toEqual([
|
expect([
|
||||||
10, 30, 20, 40
|
...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), "g-b").data,
|
||||||
]);
|
]).toEqual([10, 30, 20, 40]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('thresholdBlackWhite', () => {
|
describe("thresholdBlackWhite", () => {
|
||||||
it('серый 128 относительно порога 50% — белый', () => {
|
it("серый 128 относительно порога 50% — белый", () => {
|
||||||
expect(thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 50).data[0]).toBe(255);
|
expect(
|
||||||
expect(thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 60).data[0]).toBe(0);
|
thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 50).data[0],
|
||||||
|
).toBe(255);
|
||||||
|
expect(
|
||||||
|
thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 60).data[0],
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('posterize', () => {
|
describe("posterize", () => {
|
||||||
it('два уровня квантуют в чёрное и белое', () => {
|
it("два уровня квантуют в чёрное и белое", () => {
|
||||||
const out = posterize(
|
const out = posterize(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[100, 100, 100, 255],
|
[100, 100, 100, 255],
|
||||||
[200, 200, 200, 255]
|
[200, 200, 200, 255],
|
||||||
]),
|
]),
|
||||||
2
|
2,
|
||||||
);
|
);
|
||||||
expect(out.data[0]).toBe(0);
|
expect(out.data[0]).toBe(0);
|
||||||
expect(out.data[4]).toBe(255);
|
expect(out.data[4]).toBe(255);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('twoColors', () => {
|
describe("twoColors", () => {
|
||||||
it('яркие пиксели получают светлый цвет, тёмные — тёмный', () => {
|
it("яркие пиксели получают светлый цвет, тёмные — тёмный", () => {
|
||||||
const out = twoColors(
|
const out = twoColors(
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
[250, 250, 250, 255],
|
[250, 250, 250, 255],
|
||||||
[10, 10, 10, 128]
|
[10, 10, 10, 128],
|
||||||
]),
|
]),
|
||||||
'#ff0000',
|
"#ff0000",
|
||||||
'#00ff00',
|
"#00ff00",
|
||||||
50
|
50,
|
||||||
);
|
);
|
||||||
expect([...out.data.slice(0, 4)]).toEqual([255, 0, 0, 255]);
|
expect([...out.data.slice(0, 4)]).toEqual([255, 0, 0, 255]);
|
||||||
expect([...out.data.slice(4, 8)]).toEqual([0, 255, 0, 128]);
|
expect([...out.data.slice(4, 8)]).toEqual([0, 255, 0, 128]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('grayscale', () => {
|
describe("grayscale", () => {
|
||||||
it('считает luma по весам BT.601 с округлением', () => {
|
it("считает luma по весам BT.601 с округлением", () => {
|
||||||
const out = grayscale(
|
const out = grayscale(
|
||||||
makeImage(3, 1, [
|
makeImage(3, 1, [
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[0, 255, 0, 255],
|
[0, 255, 0, 255],
|
||||||
[0, 0, 255, 255]
|
[0, 0, 255, 255],
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
const rgb = [...out.data].reduce<number[]>((acc, v, i) => {
|
const rgb = [...out.data].reduce<number[]>((acc, v, i) => {
|
||||||
if (i % 4 === 0) acc.push(v);
|
if (i % 4 === 0) acc.push(v);
|
||||||
@@ -138,7 +146,7 @@ describe('grayscale', () => {
|
|||||||
expect(rgb).toEqual([76, 150, 29]);
|
expect(rgb).toEqual([76, 150, 29]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('сохраняет альфу и не мутирует вход', () => {
|
it("сохраняет альфу и не мутирует вход", () => {
|
||||||
const img = makeImage(1, 1, [[10, 20, 30, 200]]);
|
const img = makeImage(1, 1, [[10, 20, 30, 200]]);
|
||||||
const out = grayscale(img);
|
const out = grayscale(img);
|
||||||
expect([...out.data]).toEqual([18, 18, 18, 200]);
|
expect([...out.data]).toEqual([18, 18, 18, 200]);
|
||||||
@@ -146,89 +154,91 @@ describe('grayscale', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('invert', () => {
|
describe("invert", () => {
|
||||||
it('инвертирует RGB, не трогая альфу', () => {
|
it("инвертирует RGB, не трогая альфу", () => {
|
||||||
const out = invert(makeImage(1, 1, [[10, 200, 30, 7]]));
|
const out = invert(makeImage(1, 1, [[10, 200, 30, 7]]));
|
||||||
expect([...out.data]).toEqual([245, 55, 225, 7]);
|
expect([...out.data]).toEqual([245, 55, 225, 7]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('brightnessContrast', () => {
|
describe("brightnessContrast", () => {
|
||||||
const pixel = (r: number) => makeImage(1, 1, [[r, r, r, 255]]);
|
const pixel = (r: number) => makeImage(1, 1, [[r, r, r, 255]]);
|
||||||
const red = (out: number[]) => [out[0], out[1], out[2]];
|
const red = (out: number[]) => [out[0], out[1], out[2]];
|
||||||
|
|
||||||
it('b=0, c=0 — тождественное преобразование', () => {
|
it("b=0, c=0 — тождественное преобразование", () => {
|
||||||
const out = brightnessContrast(pixel(77), 0, 0);
|
const out = brightnessContrast(pixel(77), 0, 0);
|
||||||
expect(red([...out.data])).toEqual([77, 77, 77]);
|
expect(red([...out.data])).toEqual([77, 77, 77]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('brightness +100 насыщает всё в белый', () => {
|
it("brightness +100 насыщает всё в белый", () => {
|
||||||
const out = brightnessContrast(pixel(10), 100, 0);
|
const out = brightnessContrast(pixel(10), 100, 0);
|
||||||
expect(red([...out.data])).toEqual([255, 255, 255]);
|
expect(red([...out.data])).toEqual([255, 255, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('brightness -100 заливает чёрным', () => {
|
it("brightness -100 заливает чёрным", () => {
|
||||||
const out = brightnessContrast(pixel(240), -100, 0);
|
const out = brightnessContrast(pixel(240), -100, 0);
|
||||||
expect(red([...out.data])).toEqual([0, 0, 0]);
|
expect(red([...out.data])).toEqual([0, 0, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contrast -100 сводит всё к серому 128', () => {
|
it("contrast -100 сводит всё к серому 128", () => {
|
||||||
const out = brightnessContrast(pixel(30), 0, -100);
|
const out = brightnessContrast(pixel(30), 0, -100);
|
||||||
expect(red([...out.data])).toEqual([128, 128, 128]);
|
expect(red([...out.data])).toEqual([128, 128, 128]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('параметры вне диапазона клампятся', () => {
|
it("параметры вне диапазона клампятся", () => {
|
||||||
const out = brightnessContrast(pixel(10), 150, 0);
|
const out = brightnessContrast(pixel(10), 150, 0);
|
||||||
expect(red([...out.data])).toEqual([255, 255, 255]);
|
expect(red([...out.data])).toEqual([255, 255, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('альфа не меняется', () => {
|
it("альфа не меняется", () => {
|
||||||
const out = brightnessContrast(makeImage(1, 1, [[10, 20, 30, 64]]), 50, 50);
|
const out = brightnessContrast(makeImage(1, 1, [[10, 20, 30, 64]]), 50, 50);
|
||||||
expect(out.data[3]).toBe(64);
|
expect(out.data[3]).toBe(64);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('gammaCorrection', () => {
|
describe("gammaCorrection", () => {
|
||||||
it('гамма 1 — идентичность', () => {
|
it("гамма 1 — идентичность", () => {
|
||||||
const img = makeImage(1, 1, [[64, 128, 192, 255]]);
|
const img = makeImage(1, 1, [[64, 128, 192, 255]]);
|
||||||
expect([...gammaCorrection(img, 1).data]).toEqual([64, 128, 192, 255]);
|
expect([...gammaCorrection(img, 1).data]).toEqual([64, 128, 192, 255]);
|
||||||
});
|
});
|
||||||
it('гамма 2 удваивает яркость (64 → 128)', () => {
|
it("гамма 2 удваивает яркость (64 → 128)", () => {
|
||||||
const out = gammaCorrection(makeImage(1, 1, [[64, 64, 64, 255]]), 2);
|
const out = gammaCorrection(makeImage(1, 1, [[64, 64, 64, 255]]), 2);
|
||||||
expect(out.data[0]).toBe(128);
|
expect(out.data[0]).toBe(128);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('autoContrast', () => {
|
describe("autoContrast", () => {
|
||||||
it('растягивает диапазон [10..200] до [0..255]', () => {
|
it("растягивает диапазон [10..200] до [0..255]", () => {
|
||||||
const out = autoContrast(makeImage(2, 1, [
|
const out = autoContrast(
|
||||||
[10, 10, 10, 255],
|
makeImage(2, 1, [
|
||||||
[200, 200, 200, 255]
|
[10, 10, 10, 255],
|
||||||
]));
|
[200, 200, 200, 255],
|
||||||
|
]),
|
||||||
|
);
|
||||||
expect(out.data[0]).toBe(0);
|
expect(out.data[0]).toBe(0);
|
||||||
expect(out.data[4]).toBe(255);
|
expect(out.data[4]).toBe(255);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('temperature', () => {
|
describe("temperature", () => {
|
||||||
it('положительная — теплее (красный ↑, синий ↓)', () => {
|
it("положительная — теплее (красный ↑, синий ↓)", () => {
|
||||||
const out = temperature(makeImage(1, 1, [[128, 128, 128, 255]]), 50);
|
const out = temperature(makeImage(1, 1, [[128, 128, 128, 255]]), 50);
|
||||||
expect(out.data[0]).toBeGreaterThan(128);
|
expect(out.data[0]).toBeGreaterThan(128);
|
||||||
expect(out.data[2]).toBeLessThan(128);
|
expect(out.data[2]).toBeLessThan(128);
|
||||||
});
|
});
|
||||||
it('нулевая температура не меняет', () => {
|
it("нулевая температура не меняет", () => {
|
||||||
const img = makeImage(1, 1, [[128, 128, 128, 255]]);
|
const img = makeImage(1, 1, [[128, 128, 128, 255]]);
|
||||||
expect([...temperature(img, 0).data]).toEqual([...img.data]);
|
expect([...temperature(img, 0).data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('tint', () => {
|
describe("tint", () => {
|
||||||
it('сила 100 на белом даёт чистый цвет', () => {
|
it("сила 100 на белом даёт чистый цвет", () => {
|
||||||
const out = tint(makeImage(1, 1, [[255, 255, 255, 255]]), '#ff0000', 100);
|
const out = tint(makeImage(1, 1, [[255, 255, 255, 255]]), "#ff0000", 100);
|
||||||
expect([...out.data]).toEqual([255, 0, 0, 255]);
|
expect([...out.data]).toEqual([255, 0, 0, 255]);
|
||||||
});
|
});
|
||||||
it('сила 0 — идентичность', () => {
|
it("сила 0 — идентичность", () => {
|
||||||
const img = makeImage(1, 1, [[100, 150, 200, 255]]);
|
const img = makeImage(1, 1, [[100, 150, 200, 255]]);
|
||||||
expect([...tint(img, '#ff0000', 0).data]).toEqual([...img.data]);
|
expect([...tint(img, "#ff0000", 0).data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+36
-21
@@ -1,9 +1,9 @@
|
|||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
||||||
import { ToolError } from './errors';
|
import { ToolError } from "./errors";
|
||||||
|
|
||||||
export type RgbChannel = 'red' | 'green' | 'blue';
|
export type RgbChannel = "red" | "green" | "blue";
|
||||||
|
|
||||||
export type ChannelSwapPair = 'r-g' | 'r-b' | 'g-b';
|
export type ChannelSwapPair = "r-g" | "r-b" | "g-b";
|
||||||
|
|
||||||
export function setOpacity(img: PixelImage, percent: number): PixelImage {
|
export function setOpacity(img: PixelImage, percent: number): PixelImage {
|
||||||
const factor = clamp(percent, 0, 100) / 100;
|
const factor = clamp(percent, 0, 100) / 100;
|
||||||
@@ -74,7 +74,10 @@ function hueComponent(p: number, q: number, t: number): number {
|
|||||||
|
|
||||||
const CHANNEL_INDEX: Record<RgbChannel, number> = { red: 0, green: 1, blue: 2 };
|
const CHANNEL_INDEX: Record<RgbChannel, number> = { red: 0, green: 1, blue: 2 };
|
||||||
|
|
||||||
export function extractChannel(img: PixelImage, channel: RgbChannel): PixelImage {
|
export function extractChannel(
|
||||||
|
img: PixelImage,
|
||||||
|
channel: RgbChannel,
|
||||||
|
): PixelImage {
|
||||||
const index = CHANNEL_INDEX[channel];
|
const index = CHANNEL_INDEX[channel];
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
@@ -88,12 +91,15 @@ export function extractChannel(img: PixelImage, channel: RgbChannel): PixelImage
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SWAP_INDEX: Record<ChannelSwapPair, [number, number]> = {
|
const SWAP_INDEX: Record<ChannelSwapPair, [number, number]> = {
|
||||||
'r-g': [0, 1],
|
"r-g": [0, 1],
|
||||||
'r-b': [0, 2],
|
"r-b": [0, 2],
|
||||||
'g-b': [1, 2]
|
"g-b": [1, 2],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function swapChannels(img: PixelImage, pair: ChannelSwapPair): PixelImage {
|
export function swapChannels(
|
||||||
|
img: PixelImage,
|
||||||
|
pair: ChannelSwapPair,
|
||||||
|
): PixelImage {
|
||||||
const [a, b] = SWAP_INDEX[pair];
|
const [a, b] = SWAP_INDEX[pair];
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
@@ -108,11 +114,15 @@ export function swapChannels(img: PixelImage, pair: ChannelSwapPair): PixelImage
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function thresholdBlackWhite(img: PixelImage, thresholdPercent: number): PixelImage {
|
export function thresholdBlackWhite(
|
||||||
|
img: PixelImage,
|
||||||
|
thresholdPercent: number,
|
||||||
|
): PixelImage {
|
||||||
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
const luma =
|
||||||
|
0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||||
const v = luma >= threshold ? 255 : 0;
|
const v = luma >= threshold ? 255 : 0;
|
||||||
out.data[i] = v;
|
out.data[i] = v;
|
||||||
out.data[i + 1] = v;
|
out.data[i + 1] = v;
|
||||||
@@ -128,7 +138,9 @@ export function posterize(img: PixelImage, levels: number): PixelImage {
|
|||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
for (let ch = 0; ch < 3; ch++) {
|
for (let ch = 0; ch < 3; ch++) {
|
||||||
out.data[i + ch] = Math.round(Math.round(img.data[i + ch] / stepSize) * stepSize);
|
out.data[i + ch] = Math.round(
|
||||||
|
Math.round(img.data[i + ch] / stepSize) * stepSize,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
out.data[i + 3] = img.data[i + 3];
|
out.data[i + 3] = img.data[i + 3];
|
||||||
}
|
}
|
||||||
@@ -139,14 +151,15 @@ export function twoColors(
|
|||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
lightHex: string,
|
lightHex: string,
|
||||||
darkHex: string,
|
darkHex: string,
|
||||||
thresholdPercent: number
|
thresholdPercent: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const [lr, lg, lb] = parseColor(lightHex);
|
const [lr, lg, lb] = parseColor(lightHex);
|
||||||
const [dr, dg, db] = parseColor(darkHex);
|
const [dr, dg, db] = parseColor(darkHex);
|
||||||
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
const luma =
|
||||||
|
0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||||
if (luma >= threshold) {
|
if (luma >= threshold) {
|
||||||
out.data[i] = lr;
|
out.data[i] = lr;
|
||||||
out.data[i + 1] = lg;
|
out.data[i + 1] = lg;
|
||||||
@@ -164,20 +177,21 @@ export function twoColors(
|
|||||||
function parseColor(hex: string): [number, number, number] {
|
function parseColor(hex: string): [number, number, number] {
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new ToolError('errors.badHex', { value: hex });
|
throw new ToolError("errors.badHex", { value: hex });
|
||||||
}
|
}
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
return [
|
return [
|
||||||
parseInt(digits.slice(0, 2), 16),
|
parseInt(digits.slice(0, 2), 16),
|
||||||
parseInt(digits.slice(2, 4), 16),
|
parseInt(digits.slice(2, 4), 16),
|
||||||
parseInt(digits.slice(4, 6), 16)
|
parseInt(digits.slice(4, 6), 16),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function grayscale(img: PixelImage): PixelImage {
|
export function grayscale(img: PixelImage): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < img.data.length; i += 4) {
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
const luma =
|
||||||
|
0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||||
out.data[i] = luma;
|
out.data[i] = luma;
|
||||||
out.data[i + 1] = luma;
|
out.data[i + 1] = luma;
|
||||||
out.data[i + 2] = luma;
|
out.data[i + 2] = luma;
|
||||||
@@ -200,7 +214,7 @@ export function invert(img: PixelImage): PixelImage {
|
|||||||
export function brightnessContrast(
|
export function brightnessContrast(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
brightness: number,
|
brightness: number,
|
||||||
contrast: number
|
contrast: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const offset = (clamp(brightness, -100, 100) / 100) * 255;
|
const offset = (clamp(brightness, -100, 100) / 100) * 255;
|
||||||
const c = (clamp(contrast, -100, 100) / 100) * 255;
|
const c = (clamp(contrast, -100, 100) / 100) * 255;
|
||||||
@@ -217,7 +231,8 @@ export function brightnessContrast(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function rgbToHex(r: number, g: number, b: number): string {
|
export function rgbToHex(r: number, g: number, b: number): string {
|
||||||
const byte = (v: number) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0');
|
const byte = (v: number) =>
|
||||||
|
clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0");
|
||||||
return `#${byte(r)}${byte(g)}${byte(b)}`;
|
return `#${byte(r)}${byte(g)}${byte(b)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,11 +301,11 @@ export function temperature(img: PixelImage, percent: number): PixelImage {
|
|||||||
export function tint(
|
export function tint(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
colorHex: string,
|
colorHex: string,
|
||||||
strengthPercent: number
|
strengthPercent: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const s = clamp(strengthPercent, 0, 100) / 100;
|
const s = clamp(strengthPercent, 0, 100) / 100;
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
||||||
if (!match) throw new ToolError('errors.badHex', { value: colorHex });
|
if (!match) throw new ToolError("errors.badHex", { value: colorHex });
|
||||||
const d = match[1];
|
const d = match[1];
|
||||||
const tr = parseInt(d.slice(0, 2), 16) / 255;
|
const tr = parseInt(d.slice(0, 2), 16) / 255;
|
||||||
const tg = parseInt(d.slice(2, 4), 16) / 255;
|
const tg = parseInt(d.slice(2, 4), 16) / 255;
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { COMPRESSION_LEVELS, findMaxColorsWithin } from './compress';
|
import { COMPRESSION_LEVELS, findMaxColorsWithin } from "./compress";
|
||||||
|
|
||||||
describe('COMPRESSION_LEVELS', () => {
|
describe("COMPRESSION_LEVELS", () => {
|
||||||
it('пресеты упорядочены по убыванию цветов', () => {
|
it("пресеты упорядочены по убыванию цветов", () => {
|
||||||
const values = Object.values(COMPRESSION_LEVELS);
|
const values = Object.values(COMPRESSION_LEVELS);
|
||||||
for (let i = 1; i < values.length; i++) expect(values[i - 1]).toBeGreaterThan(values[i]);
|
for (let i = 1; i < values.length; i++)
|
||||||
|
expect(values[i - 1]).toBeGreaterThan(values[i]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findMaxColorsWithin', () => {
|
describe("findMaxColorsWithin", () => {
|
||||||
const sizeOf = (k: number) => 1000 + k * 100; // размер растёт с k
|
const sizeOf = (k: number) => 1000 + k * 100; // размер растёт с k
|
||||||
|
|
||||||
it('подбирает максимум k, укладывающийся в цель', async () => {
|
it("подбирает максимум k, укладывающийся в цель", async () => {
|
||||||
// target 5200 → подходит k≤42 → ожидаем 42 при maxK≥42
|
// target 5200 → подходит k≤42 → ожидаем 42 при maxK≥42
|
||||||
const k = await findMaxColorsWithin(5200, 256, async (kk) => sizeOf(kk));
|
const k = await findMaxColorsWithin(5200, 256, async (kk) => sizeOf(kk));
|
||||||
expect(k).toBeGreaterThanOrEqual(40);
|
expect(k).toBeGreaterThanOrEqual(40);
|
||||||
expect(sizeOf(k)).toBeLessThanOrEqual(5200);
|
expect(sizeOf(k)).toBeLessThanOrEqual(5200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('цель достигается даже на минимуме — возвращает 2', async () => {
|
it("цель достигается даже на минимуме — возвращает 2", async () => {
|
||||||
const k = await findMaxColorsWithin(50, 256, async (kk) => sizeOf(kk));
|
const k = await findMaxColorsWithin(50, 256, async (kk) => sizeOf(kk));
|
||||||
expect(k).toBe(2);
|
expect(k).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('encodeSize null трактуется как провал', async () => {
|
it("encodeSize null трактуется как провал", async () => {
|
||||||
const k = await findMaxColorsWithin(999999, 16, async () => null);
|
const k = await findMaxColorsWithin(999999, 16, async () => null);
|
||||||
expect(k).toBe(2);
|
expect(k).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('бинарный поиск сходится быстрее полного перебора', async () => {
|
it("бинарный поиск сходится быстрее полного перебора", async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
await findMaxColorsWithin(30000, 256, async (kk) => {
|
await findMaxColorsWithin(30000, 256, async (kk) => {
|
||||||
calls++;
|
calls++;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const COMPRESSION_LEVELS = {
|
|||||||
light: 192,
|
light: 192,
|
||||||
balanced: 96,
|
balanced: 96,
|
||||||
strong: 44,
|
strong: 44,
|
||||||
extreme: 16
|
extreme: 16,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type CompressionLevel = keyof typeof COMPRESSION_LEVELS;
|
export type CompressionLevel = keyof typeof COMPRESSION_LEVELS;
|
||||||
@@ -16,7 +16,7 @@ export type CompressionLevel = keyof typeof COMPRESSION_LEVELS;
|
|||||||
export async function findMaxColorsWithin(
|
export async function findMaxColorsWithin(
|
||||||
targetBytes: number,
|
targetBytes: number,
|
||||||
maxK: number,
|
maxK: number,
|
||||||
encodeSize: (k: number) => Promise<number | null>
|
encodeSize: (k: number) => Promise<number | null>,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const hi = Math.max(2, Math.round(maxK));
|
const hi = Math.max(2, Math.round(maxK));
|
||||||
let ok = 2;
|
let ok = 2;
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { convolve, gaussianBlur, sharpen } from './convolution';
|
import { convolve, gaussianBlur, sharpen } from "./convolution";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
|
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
|
||||||
|
|
||||||
describe('convolve', () => {
|
describe("convolve", () => {
|
||||||
it('крестовое ядро резкости на полоске из трёх пикселей', () => {
|
it("крестовое ядро резкости на полоске из трёх пикселей", () => {
|
||||||
const out = convolve(
|
const out = convolve(
|
||||||
makeImage(3, 1, [
|
makeImage(3, 1, [
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[100, 100, 100, 255],
|
[100, 100, 100, 255],
|
||||||
[0, 0, 0, 255]
|
[0, 0, 0, 255],
|
||||||
]),
|
]),
|
||||||
SHARPEN_KERNEL,
|
SHARPEN_KERNEL,
|
||||||
3
|
3,
|
||||||
);
|
);
|
||||||
expect([...out.data.slice(4, 8)]).toEqual([255, 255, 255, 255]);
|
expect([...out.data.slice(4, 8)]).toEqual([255, 255, 255, 255]);
|
||||||
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 255]);
|
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 255]);
|
||||||
@@ -23,39 +23,41 @@ describe('convolve', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
[2, 3],
|
[2, 3],
|
||||||
[3.5, 3],
|
[3.5, 3],
|
||||||
[0, 3]
|
[0, 3],
|
||||||
])('бросает ошибку на некорректном размере ядра %i', (size) => {
|
])("бросает ошибку на некорректном размере ядра %i", (size) => {
|
||||||
expect(() => convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number)).toThrow();
|
expect(() =>
|
||||||
|
convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number),
|
||||||
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sharpen', () => {
|
describe("sharpen", () => {
|
||||||
it('сила 0 возвращает копию', () => {
|
it("сила 0 возвращает копию", () => {
|
||||||
const img = makeImage(2, 2, [
|
const img = makeImage(2, 2, [
|
||||||
[10, 20, 30, 255],
|
[10, 20, 30, 255],
|
||||||
[40, 50, 60, 128],
|
[40, 50, 60, 128],
|
||||||
[70, 80, 90, 255],
|
[70, 80, 90, 255],
|
||||||
[100, 110, 120, 200]
|
[100, 110, 120, 200],
|
||||||
]);
|
]);
|
||||||
expect([...sharpen(img, 0).data]).toEqual([...img.data]);
|
expect([...sharpen(img, 0).data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('сила 100 применяет чистое ядро резкости', () => {
|
it("сила 100 применяет чистое ядро резкости", () => {
|
||||||
const out = sharpen(
|
const out = sharpen(
|
||||||
makeImage(3, 1, [
|
makeImage(3, 1, [
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[100, 100, 100, 255],
|
[100, 100, 100, 255],
|
||||||
[0, 0, 0, 255]
|
[0, 0, 0, 255],
|
||||||
]),
|
]),
|
||||||
100
|
100,
|
||||||
);
|
);
|
||||||
expect(out.data[4]).toBe(255);
|
expect(out.data[4]).toBe(255);
|
||||||
expect(out.data[0]).toBe(0);
|
expect(out.data[0]).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('gaussianBlur', () => {
|
describe("gaussianBlur", () => {
|
||||||
it('постоянное изображение не меняется ни в RGB, ни в альфе', () => {
|
it("постоянное изображение не меняется ни в RGB, ни в альфе", () => {
|
||||||
const img = makeImage(3, 3, new Array(9).fill([40, 80, 120, 128]));
|
const img = makeImage(3, 3, new Array(9).fill([40, 80, 120, 128]));
|
||||||
const out = gaussianBlur(img, 16);
|
const out = gaussianBlur(img, 16);
|
||||||
for (let i = 0; i < out.data.length; i++) {
|
for (let i = 0; i < out.data.length; i++) {
|
||||||
@@ -63,13 +65,15 @@ describe('gaussianBlur', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('далёкие углы остаются прозрачными, цвет центра не искажается', () => {
|
it("далёкие углы остаются прозрачными, цвет центра не искажается", () => {
|
||||||
const size = 61;
|
const size = 61;
|
||||||
const pixels: number[][] = [];
|
const pixels: number[][] = [];
|
||||||
for (let y = 0; y < size; y++) {
|
for (let y = 0; y < size; y++) {
|
||||||
for (let x = 0; x < size; x++) {
|
for (let x = 0; x < size; x++) {
|
||||||
pixels.push(
|
pixels.push(
|
||||||
x >= 26 && x <= 34 && y >= 26 && y <= 34 ? [200, 50, 25, 255] : [0, 0, 0, 0]
|
x >= 26 && x <= 34 && y >= 26 && y <= 34
|
||||||
|
? [200, 50, 25, 255]
|
||||||
|
: [0, 0, 0, 0],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,50 +89,50 @@ describe('gaussianBlur', () => {
|
|||||||
expect(out.data[center + 2]).toBe(25);
|
expect(out.data[center + 2]).toBe(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('симметричный вход даёт симметричный результат', () => {
|
it("симметричный вход даёт симметричный результат", () => {
|
||||||
const leftByRow = [
|
const leftByRow = [
|
||||||
[
|
[
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[10, 20, 30, 255],
|
[10, 20, 30, 255],
|
||||||
[64, 64, 64, 64],
|
[64, 64, 64, 64],
|
||||||
[5, 5, 5, 200]
|
[5, 5, 5, 200],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[10, 20, 30, 255],
|
[10, 20, 30, 255],
|
||||||
[200, 100, 50, 255],
|
[200, 100, 50, 255],
|
||||||
[1, 2, 3, 4],
|
[1, 2, 3, 4],
|
||||||
[90, 90, 90, 250]
|
[90, 90, 90, 250],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[64, 64, 64, 64],
|
[64, 64, 64, 64],
|
||||||
[1, 2, 3, 4],
|
[1, 2, 3, 4],
|
||||||
[128, 128, 128, 128],
|
[128, 128, 128, 128],
|
||||||
[40, 40, 40, 240]
|
[40, 40, 40, 240],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[200, 100, 50, 255],
|
[200, 100, 50, 255],
|
||||||
[90, 90, 90, 250],
|
[90, 90, 90, 250],
|
||||||
[40, 40, 40, 240],
|
[40, 40, 40, 240],
|
||||||
[7, 7, 7, 255]
|
[7, 7, 7, 255],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[10, 20, 30, 255],
|
[10, 20, 30, 255],
|
||||||
[1, 2, 3, 4],
|
[1, 2, 3, 4],
|
||||||
[64, 64, 64, 64],
|
[64, 64, 64, 64],
|
||||||
[90, 90, 90, 250]
|
[90, 90, 90, 250],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[64, 64, 64, 64],
|
[64, 64, 64, 64],
|
||||||
[200, 100, 50, 255],
|
[200, 100, 50, 255],
|
||||||
[5, 5, 5, 200],
|
[5, 5, 5, 200],
|
||||||
[1, 2, 3, 4]
|
[1, 2, 3, 4],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[5, 5, 5, 200],
|
[5, 5, 5, 200],
|
||||||
[40, 40, 40, 240],
|
[40, 40, 40, 240],
|
||||||
[90, 90, 90, 250],
|
[90, 90, 90, 250],
|
||||||
[128, 128, 128, 128]
|
[128, 128, 128, 128],
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
const pixels: number[][] = [];
|
const pixels: number[][] = [];
|
||||||
for (let y = 0; y < 7; y++) {
|
for (let y = 0; y < 7; y++) {
|
||||||
@@ -146,7 +150,7 @@ describe('gaussianBlur', () => {
|
|||||||
blurred.data[ri],
|
blurred.data[ri],
|
||||||
blurred.data[ri + 1],
|
blurred.data[ri + 1],
|
||||||
blurred.data[ri + 2],
|
blurred.data[ri + 2],
|
||||||
blurred.data[ri + 3]
|
blurred.data[ri + 3],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
||||||
import { ToolError } from './errors';
|
import { ToolError } from "./errors";
|
||||||
|
|
||||||
type Plane = Float64Array;
|
type Plane = Float64Array;
|
||||||
|
|
||||||
export function convolve(
|
export function convolve(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
kernel: readonly number[],
|
kernel: readonly number[],
|
||||||
size: number
|
size: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
|
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
|
||||||
throw new ToolError('errors.radiusInt');
|
throw new ToolError("errors.radiusInt");
|
||||||
}
|
}
|
||||||
if (kernel.length !== size * size) {
|
if (kernel.length !== size * size) {
|
||||||
throw new ToolError('errors.kernelSize');
|
throw new ToolError("errors.kernelSize");
|
||||||
}
|
}
|
||||||
const half = Math.floor(size / 2);
|
const half = Math.floor(size / 2);
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
@@ -24,12 +24,14 @@ export function convolve(
|
|||||||
const sy = clampInt(y + ky - half, 0, img.height - 1);
|
const sy = clampInt(y + ky - half, 0, img.height - 1);
|
||||||
for (let kx = 0; kx < size; kx++) {
|
for (let kx = 0; kx < size; kx++) {
|
||||||
const sx = clampInt(x + kx - half, 0, img.width - 1);
|
const sx = clampInt(x + kx - half, 0, img.width - 1);
|
||||||
acc += img.data[(sy * img.width + sx) * 4 + ch] * kernel[ky * size + kx];
|
acc +=
|
||||||
|
img.data[(sy * img.width + sx) * 4 + ch] * kernel[ky * size + kx];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.data[(y * out.width + x) * 4 + ch] = acc;
|
out.data[(y * out.width + x) * 4 + ch] = acc;
|
||||||
}
|
}
|
||||||
out.data[(y * out.width + x) * 4 + 3] = img.data[(y * img.width + x) * 4 + 3];
|
out.data[(y * out.width + x) * 4 + 3] =
|
||||||
|
img.data[(y * img.width + x) * 4 + 3];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -103,7 +105,13 @@ export function gaussianBlur(img: PixelImage, radiusPx: number): PixelImage {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function blurPlanePass(plane: Plane, tmp: Plane, w: number, h: number, r: number): void {
|
function blurPlanePass(
|
||||||
|
plane: Plane,
|
||||||
|
tmp: Plane,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
r: number,
|
||||||
|
): void {
|
||||||
blurPlaneHorizontal(plane, tmp, w, h, r);
|
blurPlaneHorizontal(plane, tmp, w, h, r);
|
||||||
blurPlaneVertical(tmp, plane, w, h, r);
|
blurPlaneVertical(tmp, plane, w, h, r);
|
||||||
}
|
}
|
||||||
@@ -113,7 +121,7 @@ function blurPlaneHorizontal(
|
|||||||
dst: Plane,
|
dst: Plane,
|
||||||
w: number,
|
w: number,
|
||||||
h: number,
|
h: number,
|
||||||
r: number
|
r: number,
|
||||||
): void {
|
): void {
|
||||||
const div = 2 * r + 1;
|
const div = 2 * r + 1;
|
||||||
const inv = 1 / div;
|
const inv = 1 / div;
|
||||||
@@ -137,7 +145,7 @@ function blurPlaneVertical(
|
|||||||
dst: Plane,
|
dst: Plane,
|
||||||
w: number,
|
w: number,
|
||||||
h: number,
|
h: number,
|
||||||
r: number
|
r: number,
|
||||||
): void {
|
): void {
|
||||||
const div = 2 * r + 1;
|
const div = 2 * r + 1;
|
||||||
const inv = 1 / div;
|
const inv = 1 / div;
|
||||||
@@ -160,7 +168,8 @@ function boxesForGauss(sigma: number, boxes: number): number[] {
|
|||||||
let wl = Math.floor(wIdeal);
|
let wl = Math.floor(wIdeal);
|
||||||
if (wl % 2 === 0) wl--;
|
if (wl % 2 === 0) wl--;
|
||||||
const wu = wl + 2;
|
const wu = wl + 2;
|
||||||
const mIdeal = (12 * sigma * sigma - boxes * wl * wl - boxes * wl - boxes) / (4 * wl + 4);
|
const mIdeal =
|
||||||
|
(12 * sigma * sigma - boxes * wl * wl - boxes * wl - boxes) / (4 * wl + 4);
|
||||||
const m = Math.round(mIdeal);
|
const m = Math.round(mIdeal);
|
||||||
const sizes: number[] = [];
|
const sizes: number[] = [];
|
||||||
for (let i = 0; i < boxes; i++) {
|
for (let i = 0; i < boxes; i++) {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatStamp } from './datefmt';
|
import { formatStamp } from "./datefmt";
|
||||||
|
|
||||||
const d = new Date(2026, 7, 25, 9, 5, 3);
|
const d = new Date(2026, 7, 25, 9, 5, 3);
|
||||||
|
|
||||||
describe('formatStamp', () => {
|
describe("formatStamp", () => {
|
||||||
it('разворачивает все базовые токены', () => {
|
it("разворачивает все базовые токены", () => {
|
||||||
expect(formatStamp(d, 'YYYY-MM-DD hh:mm:ss')).toBe('2026-08-25 09:05:03');
|
expect(formatStamp(d, "YYYY-MM-DD hh:mm:ss")).toBe("2026-08-25 09:05:03");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('произвольный текст между токенами сохраняется', () => {
|
it("произвольный текст между токенами сохраняется", () => {
|
||||||
expect(formatStamp(d, 'DD.MM.YYYY')).toBe('25.08.2026');
|
expect(formatStamp(d, "DD.MM.YYYY")).toBe("25.08.2026");
|
||||||
expect(formatStamp(d, 'YYYY год, MM месяц')).toBe('2026 год, 08 месяц');
|
expect(formatStamp(d, "YYYY год, MM месяц")).toBe("2026 год, 08 месяц");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('неизвестные последовательности не трогаются', () => {
|
it("неизвестные последовательности не трогаются", () => {
|
||||||
expect(formatStamp(d, 'YYYYYY MMМ')).toBe('2026YY 08М');
|
expect(formatStamp(d, "YYYYYY MMМ")).toBe("2026YY 08М");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const PAD2 = (n: number) => String(n).padStart(2, '0');
|
const PAD2 = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Мини-форматтер штампа даты: токены YYYY MM DD hh mm ss заменяются
|
* Мини-форматтер штампа даты: токены YYYY MM DD hh mm ss заменяются
|
||||||
@@ -7,17 +7,17 @@ const PAD2 = (n: number) => String(n).padStart(2, '0');
|
|||||||
export function formatStamp(date: Date, pattern: string): string {
|
export function formatStamp(date: Date, pattern: string): string {
|
||||||
return pattern.replace(/YYYY|MM|DD|hh|mm|ss/g, (token) => {
|
return pattern.replace(/YYYY|MM|DD|hh|mm|ss/g, (token) => {
|
||||||
switch (token) {
|
switch (token) {
|
||||||
case 'YYYY':
|
case "YYYY":
|
||||||
return String(date.getFullYear());
|
return String(date.getFullYear());
|
||||||
case 'MM':
|
case "MM":
|
||||||
return PAD2(date.getMonth() + 1);
|
return PAD2(date.getMonth() + 1);
|
||||||
case 'DD':
|
case "DD":
|
||||||
return PAD2(date.getDate());
|
return PAD2(date.getDate());
|
||||||
case 'hh':
|
case "hh":
|
||||||
return PAD2(date.getHours());
|
return PAD2(date.getHours());
|
||||||
case 'mm':
|
case "mm":
|
||||||
return PAD2(date.getMinutes());
|
return PAD2(date.getMinutes());
|
||||||
case 'ss':
|
case "ss":
|
||||||
return PAD2(date.getSeconds());
|
return PAD2(date.getSeconds());
|
||||||
default:
|
default:
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
+94
-33
@@ -1,33 +1,44 @@
|
|||||||
import { ToolError } from './errors';
|
import { ToolError } from "./errors";
|
||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from "./types";
|
||||||
import { anchorOrigin, tileGrid, wrapText, type Position9 } from './textdraw';
|
import { anchorOrigin, tileGrid, wrapText, type Position9 } from "./textdraw";
|
||||||
|
|
||||||
export type TextFont = 'sans' | 'serif' | 'mono';
|
export type TextFont = "sans" | "serif" | "mono";
|
||||||
|
|
||||||
const FONT_STACKS: Record<TextFont, string> = {
|
const FONT_STACKS: Record<TextFont, string> = {
|
||||||
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||||
serif: 'Georgia, "Times New Roman", serif',
|
serif: 'Georgia, "Times New Roman", serif',
|
||||||
mono: 'ui-monospace, "Cascadia Code", Consolas, monospace'
|
mono: 'ui-monospace, "Cascadia Code", Consolas, monospace',
|
||||||
};
|
};
|
||||||
|
|
||||||
function ctx2d(w: number, h: number): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
function ctx2d(
|
||||||
const canvas = document.createElement('canvas');
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = w;
|
canvas.width = w;
|
||||||
canvas.height = h;
|
canvas.height = h;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
if (!ctx) throw new ToolError('errors.noCanvasCtx');
|
if (!ctx) throw new ToolError("errors.noCanvasCtx");
|
||||||
return { canvas, ctx };
|
return { canvas, ctx };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPixelImage(canvas: HTMLCanvasElement): PixelImage {
|
function toPixelImage(canvas: HTMLCanvasElement): PixelImage {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) throw new ToolError('errors.noCanvasCtx');
|
if (!ctx) throw new ToolError("errors.noCanvasCtx");
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
return {
|
||||||
|
width: imageData.width,
|
||||||
|
height: imageData.height,
|
||||||
|
data: imageData.data,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fontString(size: number, font: TextFont, bold: boolean): string {
|
export function fontString(
|
||||||
return `${bold ? '700 ' : '400 '}${size}px ${FONT_STACKS[font]}`;
|
size: number,
|
||||||
|
font: TextFont,
|
||||||
|
bold: boolean,
|
||||||
|
): string {
|
||||||
|
return `${bold ? "700 " : "400 "}${size}px ${FONT_STACKS[font]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextBlockOptions {
|
export interface TextBlockOptions {
|
||||||
@@ -46,9 +57,16 @@ export interface TextBlockOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Одна надпись (с автопереносом и опциональной плашкой) поверх изображения. */
|
/** Одна надпись (с автопереносом и опциональной плашкой) поверх изображения. */
|
||||||
export function drawTextBlock(img: PixelImage, o: TextBlockOptions): PixelImage {
|
export function drawTextBlock(
|
||||||
|
img: PixelImage,
|
||||||
|
o: TextBlockOptions,
|
||||||
|
): PixelImage {
|
||||||
const { canvas, ctx } = ctx2d(img.width, img.height);
|
const { canvas, ctx } = ctx2d(img.width, img.height);
|
||||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
|
ctx.putImageData(
|
||||||
|
new ImageData(new Uint8ClampedArray(img.data), img.width, img.height),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
||||||
const maxWidth = ((o.maxWidthPercent ?? 90) / 100) * img.width;
|
const maxWidth = ((o.maxWidthPercent ?? 90) / 100) * img.width;
|
||||||
@@ -57,11 +75,18 @@ export function drawTextBlock(img: PixelImage, o: TextBlockOptions): PixelImage
|
|||||||
const ascent = o.fontSize * 0.8;
|
const ascent = o.fontSize * 0.8;
|
||||||
const blockW = Math.min(
|
const blockW = Math.min(
|
||||||
maxWidth,
|
maxWidth,
|
||||||
lines.reduce((max, s) => Math.max(max, ctx.measureText(s).width), 0)
|
lines.reduce((max, s) => Math.max(max, ctx.measureText(s).width), 0),
|
||||||
);
|
);
|
||||||
const blockH = lines.length * lineH;
|
const blockH = lines.length * lineH;
|
||||||
|
|
||||||
const origin = anchorOrigin(o.position, blockW, blockH, img.width, img.height, o.margin);
|
const origin = anchorOrigin(
|
||||||
|
o.position,
|
||||||
|
blockW,
|
||||||
|
blockH,
|
||||||
|
img.width,
|
||||||
|
img.height,
|
||||||
|
o.margin,
|
||||||
|
);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = o.opacityPercent / 100;
|
ctx.globalAlpha = o.opacityPercent / 100;
|
||||||
@@ -77,7 +102,7 @@ export function drawTextBlock(img: PixelImage, o: TextBlockOptions): PixelImage
|
|||||||
ctx.globalAlpha = o.opacityPercent / 100;
|
ctx.globalAlpha = o.opacityPercent / 100;
|
||||||
}
|
}
|
||||||
ctx.fillStyle = o.color;
|
ctx.fillStyle = o.color;
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = "alphabetic";
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
ctx.fillText(line, origin.x, origin.y + ascent + i * lineH);
|
ctx.fillText(line, origin.x, origin.y + ascent + i * lineH);
|
||||||
});
|
});
|
||||||
@@ -86,7 +111,10 @@ export function drawTextBlock(img: PixelImage, o: TextBlockOptions): PixelImage
|
|||||||
return toPixelImage(canvas);
|
return toPixelImage(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TileTextOptions extends Omit<TextBlockOptions, 'position' | 'margin' | 'angleDeg'> {
|
export interface TileTextOptions extends Omit<
|
||||||
|
TextBlockOptions,
|
||||||
|
"position" | "margin" | "angleDeg"
|
||||||
|
> {
|
||||||
stepX: number;
|
stepX: number;
|
||||||
stepY: number;
|
stepY: number;
|
||||||
angleDeg: number;
|
angleDeg: number;
|
||||||
@@ -110,7 +138,10 @@ export function renderTextToImage(o: TextToImageOptions): PixelImage {
|
|||||||
measure.font = fontString(o.fontSize, o.font, o.bold);
|
measure.font = fontString(o.fontSize, o.font, o.bold);
|
||||||
const maxWidth = o.maxTextWidth ?? 4000;
|
const maxWidth = o.maxTextWidth ?? 4000;
|
||||||
const lines = [o.text];
|
const lines = [o.text];
|
||||||
const textW = Math.min(maxWidth, Math.max(measure.measureText(o.text).width, 1));
|
const textW = Math.min(
|
||||||
|
maxWidth,
|
||||||
|
Math.max(measure.measureText(o.text).width, 1),
|
||||||
|
);
|
||||||
const lineH = o.fontSize * 1.25;
|
const lineH = o.fontSize * 1.25;
|
||||||
|
|
||||||
const w = Math.max(1, Math.ceil(textW + o.padding * 2));
|
const w = Math.max(1, Math.ceil(textW + o.padding * 2));
|
||||||
@@ -123,7 +154,7 @@ export function renderTextToImage(o: TextToImageOptions): PixelImage {
|
|||||||
}
|
}
|
||||||
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
||||||
ctx.fillStyle = o.color;
|
ctx.fillStyle = o.color;
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = "top";
|
||||||
lines.forEach((line) => ctx.fillText(line, o.padding, o.padding));
|
lines.forEach((line) => ctx.fillText(line, o.padding, o.padding));
|
||||||
return toPixelImage(canvas);
|
return toPixelImage(canvas);
|
||||||
}
|
}
|
||||||
@@ -132,8 +163,8 @@ export function renderTextToImage(o: TextToImageOptions): PixelImage {
|
|||||||
export function renderEmoji(symbol: string, size: number): PixelImage {
|
export function renderEmoji(symbol: string, size: number): PixelImage {
|
||||||
const { canvas, ctx } = ctx2d(size, size);
|
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.font = `${Math.round(size * 0.72)}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillText(symbol, size / 2, size / 2 + size * 0.04);
|
ctx.fillText(symbol, size / 2, size / 2 + size * 0.04);
|
||||||
return toPixelImage(canvas);
|
return toPixelImage(canvas);
|
||||||
}
|
}
|
||||||
@@ -147,19 +178,37 @@ export interface ImageWatermarkOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Картинка-знак поверх изображения: масштаб от ширины холста, позиция 3×3. */
|
/** Картинка-знак поверх изображения: масштаб от ширины холста, позиция 3×3. */
|
||||||
export function drawImageWatermark(img: PixelImage, o: ImageWatermarkOptions): PixelImage {
|
export function drawImageWatermark(
|
||||||
|
img: PixelImage,
|
||||||
|
o: ImageWatermarkOptions,
|
||||||
|
): PixelImage {
|
||||||
const { canvas, ctx } = ctx2d(img.width, img.height);
|
const { canvas, ctx } = ctx2d(img.width, img.height);
|
||||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
|
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 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 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 origin = anchorOrigin(
|
||||||
|
o.position,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
img.width,
|
||||||
|
img.height,
|
||||||
|
o.margin,
|
||||||
|
);
|
||||||
|
|
||||||
const markCanvas = ctx2d(o.mark.width, o.mark.height);
|
const markCanvas = ctx2d(o.mark.width, o.mark.height);
|
||||||
markCanvas.ctx.putImageData(
|
markCanvas.ctx.putImageData(
|
||||||
new ImageData(new Uint8ClampedArray(o.mark.data), o.mark.width, o.mark.height),
|
new ImageData(
|
||||||
|
new Uint8ClampedArray(o.mark.data),
|
||||||
|
o.mark.width,
|
||||||
|
o.mark.height,
|
||||||
|
),
|
||||||
|
0,
|
||||||
0,
|
0,
|
||||||
0
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
@@ -173,21 +222,33 @@ export function drawImageWatermark(img: PixelImage, o: ImageWatermarkOptions): P
|
|||||||
/** Повторяющаяся диагональная плитка текста на весь холст. */
|
/** Повторяющаяся диагональная плитка текста на весь холст. */
|
||||||
export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage {
|
export function drawTextTile(img: PixelImage, o: TileTextOptions): PixelImage {
|
||||||
const { canvas, ctx } = ctx2d(img.width, img.height);
|
const { canvas, ctx } = ctx2d(img.width, img.height);
|
||||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(img.data), img.width, img.height), 0, 0);
|
ctx.putImageData(
|
||||||
|
new ImageData(new Uint8ClampedArray(img.data), img.width, img.height),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
||||||
const sample = o.text.length > 0 ? o.text : ' ';
|
const sample = o.text.length > 0 ? o.text : " ";
|
||||||
const blockW = ctx.measureText(sample).width;
|
const blockW = ctx.measureText(sample).width;
|
||||||
const blockH = o.fontSize * 1.4;
|
const blockH = o.fontSize * 1.4;
|
||||||
|
|
||||||
const points = tileGrid(img.width, img.height, o.angleDeg, o.stepX, o.stepY, blockW, blockH);
|
const points = tileGrid(
|
||||||
|
img.width,
|
||||||
|
img.height,
|
||||||
|
o.angleDeg,
|
||||||
|
o.stepX,
|
||||||
|
o.stepY,
|
||||||
|
blockW,
|
||||||
|
blockH,
|
||||||
|
);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(img.width / 2, img.height / 2);
|
ctx.translate(img.width / 2, img.height / 2);
|
||||||
ctx.rotate((o.angleDeg * Math.PI) / 180);
|
ctx.rotate((o.angleDeg * Math.PI) / 180);
|
||||||
ctx.globalAlpha = o.opacityPercent / 100;
|
ctx.globalAlpha = o.opacityPercent / 100;
|
||||||
ctx.fillStyle = o.color;
|
ctx.fillStyle = o.color;
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = "middle";
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
ctx.fillText(sample, p.x - blockW / 2, p.y);
|
ctx.fillText(sample, p.x - blockW / 2, p.y);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { vignette } from './effects';
|
import { vignette } from "./effects";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe('vignette', () => {
|
describe("vignette", () => {
|
||||||
it('сила 0 — идентичное преобразование', () => {
|
it("сила 0 — идентичное преобразование", () => {
|
||||||
const img = makeImage(3, 3, new Array(9).fill([200, 100, 50, 255]));
|
const img = makeImage(3, 3, new Array(9).fill([200, 100, 50, 255]));
|
||||||
expect([...vignette(img, 0).data]).toEqual([...img.data]);
|
expect([...vignette(img, 0).data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('углы темнее центра', () => {
|
it("углы темнее центра", () => {
|
||||||
const img = makeImage(7, 7, new Array(49).fill([200, 200, 200, 255]));
|
const img = makeImage(7, 7, new Array(49).fill([200, 200, 200, 255]));
|
||||||
const out = vignette(img, 80);
|
const out = vignette(img, 80);
|
||||||
const centerR = out.data[(3 * 7 + 3) * 4];
|
const centerR = out.data[(3 * 7 + 3) * 4];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
||||||
|
|
||||||
export function vignette(img: PixelImage, strengthPercent: number): PixelImage {
|
export function vignette(img: PixelImage, strengthPercent: number): PixelImage {
|
||||||
const strength = Math.min(Math.max(strengthPercent, 0), 100) / 100;
|
const strength = Math.min(Math.max(strengthPercent, 0), 100) / 100;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class ToolError extends Error {
|
|||||||
|
|
||||||
constructor(key: string, vars?: ErrorVars) {
|
constructor(key: string, vars?: ErrorVars) {
|
||||||
super(key);
|
super(key);
|
||||||
this.name = 'ToolError';
|
this.name = "ToolError";
|
||||||
this.key = key;
|
this.key = key;
|
||||||
this.vars = vars;
|
this.vars = vars;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
changeCanvasSize,
|
changeCanvasSize,
|
||||||
contentBounds,
|
contentBounds,
|
||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
forceOrientation,
|
forceOrientation,
|
||||||
padToRatio,
|
padToRatio,
|
||||||
symmetricCopy,
|
symmetricCopy,
|
||||||
trimToContent
|
trimToContent,
|
||||||
} from './geometry';
|
} from "./geometry";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
const bordered = () => {
|
const bordered = () => {
|
||||||
// 4×4: рамка из прозрачных пикселей вокруг красного центра 2×2
|
// 4×4: рамка из прозрачных пикселей вокруг красного центра 2×2
|
||||||
@@ -23,12 +23,12 @@ const bordered = () => {
|
|||||||
return img;
|
return img;
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('contentBounds / trimToContent', () => {
|
describe("contentBounds / trimToContent", () => {
|
||||||
it('границы по порогу альфы', () => {
|
it("границы по порогу альфы", () => {
|
||||||
expect(contentBounds(bordered(), 0)).toEqual({ x: 1, y: 1, w: 2, h: 2 });
|
expect(contentBounds(bordered(), 0)).toEqual({ x: 1, y: 1, w: 2, h: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trim обрезает поля и сохраняет содержимое', () => {
|
it("trim обрезает поля и сохраняет содержимое", () => {
|
||||||
const out = trimToContent(bordered(), 0);
|
const out = trimToContent(bordered(), 0);
|
||||||
expect(out.width).toBe(2);
|
expect(out.width).toBe(2);
|
||||||
expect(out.height).toBe(2);
|
expect(out.height).toBe(2);
|
||||||
@@ -36,7 +36,7 @@ describe('contentBounds / trimToContent', () => {
|
|||||||
expect(out.data[3]).toBe(255);
|
expect(out.data[3]).toBe(255);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('полностью прозрачное изображение → 1×1', () => {
|
it("полностью прозрачное изображение → 1×1", () => {
|
||||||
const empty = makeImage(3, 3, new Array(9).fill([0, 0, 0, 0]));
|
const empty = makeImage(3, 3, new Array(9).fill([0, 0, 0, 0]));
|
||||||
const out = trimToContent(empty, 0);
|
const out = trimToContent(empty, 0);
|
||||||
expect(out.width).toBe(1);
|
expect(out.width).toBe(1);
|
||||||
@@ -44,43 +44,43 @@ describe('contentBounds / trimToContent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('changeCanvasSize', () => {
|
describe("changeCanvasSize", () => {
|
||||||
const img = makeImage(2, 2, [
|
const img = makeImage(2, 2, [
|
||||||
[1, 1, 1, 255],
|
[1, 1, 1, 255],
|
||||||
[2, 2, 2, 255],
|
[2, 2, 2, 255],
|
||||||
[3, 3, 3, 255],
|
[3, 3, 3, 255],
|
||||||
[4, 4, 4, 255]
|
[4, 4, 4, 255],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
it('увеличение с якорем center — прозрачные поля со всех сторон', () => {
|
it("увеличение с якорем center — прозрачные поля со всех сторон", () => {
|
||||||
const out = changeCanvasSize(img, 4, 4, 'center');
|
const out = changeCanvasSize(img, 4, 4, "center");
|
||||||
expect(out.width).toBe(4);
|
expect(out.width).toBe(4);
|
||||||
expect(out.data[3]).toBe(0);
|
expect(out.data[3]).toBe(0);
|
||||||
expect(out.data[(1 * 4 + 1) * 4 + 3]).toBe(255);
|
expect(out.data[(1 * 4 + 1) * 4 + 3]).toBe(255);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('увеличение с якорем top-left — контент прижат в угол', () => {
|
it("увеличение с якорем top-left — контент прижат в угол", () => {
|
||||||
const out = changeCanvasSize(img, 4, 4, 'top-left');
|
const out = changeCanvasSize(img, 4, 4, "top-left");
|
||||||
expect(out.data[3]).toBe(255);
|
expect(out.data[3]).toBe(255);
|
||||||
expect(out.data[(3 * 4 + 3) * 4 + 3]).toBe(0);
|
expect(out.data[(3 * 4 + 3) * 4 + 3]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('уменьшение обрезает как кроп от якоря bottom-right', () => {
|
it("уменьшение обрезает как кроп от якоря bottom-right", () => {
|
||||||
const out = changeCanvasSize(img, 1, 1, 'bottom-right');
|
const out = changeCanvasSize(img, 1, 1, "bottom-right");
|
||||||
expect(out.data[0]).toBe(4);
|
expect(out.data[0]).toBe(4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('соотношение сторон', () => {
|
describe("соотношение сторон", () => {
|
||||||
const wide = makeImage(400, 200, new Array(80000).fill([9, 9, 9, 255]));
|
const wide = makeImage(400, 200, new Array(80000).fill([9, 9, 9, 255]));
|
||||||
|
|
||||||
it('cropToRatio 1:1 из 2:1 → квадрат по высоте', () => {
|
it("cropToRatio 1:1 из 2:1 → квадрат по высоте", () => {
|
||||||
const out = cropToRatio(wide, 1);
|
const out = cropToRatio(wide, 1);
|
||||||
expect(out.width).toBe(200);
|
expect(out.width).toBe(200);
|
||||||
expect(out.height).toBe(200);
|
expect(out.height).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('padToRatio 1:1 из 2:1 → квадрат с прозрачными полями', () => {
|
it("padToRatio 1:1 из 2:1 → квадрат с прозрачными полями", () => {
|
||||||
const out = padToRatio(wide, 1);
|
const out = padToRatio(wide, 1);
|
||||||
expect(out.width).toBe(400);
|
expect(out.width).toBe(400);
|
||||||
expect(out.height).toBe(400);
|
expect(out.height).toBe(400);
|
||||||
@@ -89,39 +89,39 @@ describe('соотношение сторон', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('forceOrientation / symmetricCopy', () => {
|
describe("forceOrientation / symmetricCopy", () => {
|
||||||
it('широкое становится высоким поворотом', () => {
|
it("широкое становится высоким поворотом", () => {
|
||||||
const wide = makeImage(300, 100, new Array(30000).fill([5, 5, 5, 255]));
|
const wide = makeImage(300, 100, new Array(30000).fill([5, 5, 5, 255]));
|
||||||
const out = forceOrientation(wide, 'portrait');
|
const out = forceOrientation(wide, "portrait");
|
||||||
expect(out.width).toBe(100);
|
expect(out.width).toBe(100);
|
||||||
expect(out.height).toBe(300);
|
expect(out.height).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('квадрат не поворачивается', () => {
|
it("квадрат не поворачивается", () => {
|
||||||
const sq = makeImage(50, 50, new Array(2500).fill([5, 5, 5, 255]));
|
const sq = makeImage(50, 50, new Array(2500).fill([5, 5, 5, 255]));
|
||||||
const out = forceOrientation(sq, 'portrait');
|
const out = forceOrientation(sq, "portrait");
|
||||||
expect(out.width).toBe(50);
|
expect(out.width).toBe(50);
|
||||||
expect(out.height).toBe(50);
|
expect(out.height).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('симметричная копия удваивает ширину и зеркалит правую половину', () => {
|
it("симметричная копия удваивает ширину и зеркалит правую половину", () => {
|
||||||
const img = makeImage(2, 1, [
|
const img = makeImage(2, 1, [
|
||||||
[10, 0, 0, 255],
|
[10, 0, 0, 255],
|
||||||
[20, 0, 0, 255]
|
[20, 0, 0, 255],
|
||||||
]);
|
]);
|
||||||
const out = symmetricCopy(img, 'vertical', 'left');
|
const out = symmetricCopy(img, "vertical", "left");
|
||||||
expect(out.width).toBe(4);
|
expect(out.width).toBe(4);
|
||||||
expect(out.data[0]).toBe(10);
|
expect(out.data[0]).toBe(10);
|
||||||
expect(out.data[8]).toBe(20); // пиксель 2 = зеркало начала
|
expect(out.data[8]).toBe(20); // пиксель 2 = зеркало начала
|
||||||
expect(out.data[12]).toBe(10); // пиксель 3 = зеркало конца
|
expect(out.data[12]).toBe(10); // пиксель 3 = зеркало конца
|
||||||
});
|
});
|
||||||
|
|
||||||
it('вертикальная ось удваивает высоту', () => {
|
it("вертикальная ось удваивает высоту", () => {
|
||||||
const img = makeImage(1, 2, [
|
const img = makeImage(1, 2, [
|
||||||
[10, 0, 0, 255],
|
[10, 0, 0, 255],
|
||||||
[30, 0, 0, 255]
|
[30, 0, 0, 255],
|
||||||
]);
|
]);
|
||||||
const out = symmetricCopy(img, 'horizontal', 'top');
|
const out = symmetricCopy(img, "horizontal", "top");
|
||||||
expect(out.height).toBe(4);
|
expect(out.height).toBe(4);
|
||||||
expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // строка 2 = зеркало строки 1
|
expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // строка 2 = зеркало строки 1
|
||||||
expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // строка 3 = зеркало строки 0
|
expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // строка 3 = зеркало строки 0
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { colorSpectrum, drawGrid, randomColorBlocks } from './gen-tools';
|
import { colorSpectrum, drawGrid, randomColorBlocks } from "./gen-tools";
|
||||||
|
|
||||||
describe('colorSpectrum', () => {
|
describe("colorSpectrum", () => {
|
||||||
it('горизонтальный: левый край красный (hue 0)', () => {
|
it("горизонтальный: левый край красный (hue 0)", () => {
|
||||||
const img = colorSpectrum(100, 10, 'horizontal', 100, 50);
|
const img = colorSpectrum(100, 10, "horizontal", 100, 50);
|
||||||
expect(img.data[0]).toBeGreaterThan(200);
|
expect(img.data[0]).toBeGreaterThan(200);
|
||||||
expect(img.data[1]).toBeLessThan(60);
|
expect(img.data[1]).toBeLessThan(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('вертикальный: зелёный максимум около hue 120°', () => {
|
it("вертикальный: зелёный максимум около hue 120°", () => {
|
||||||
const img = colorSpectrum(10, 100, 'vertical', 100, 50);
|
const img = colorSpectrum(10, 100, "vertical", 100, 50);
|
||||||
const rowHue120 = Math.round((120 / 360) * 99);
|
const rowHue120 = Math.round((120 / 360) * 99);
|
||||||
const g = img.data[(rowHue120 * 10 + 5) * 4 + 1];
|
const g = img.data[(rowHue120 * 10 + 5) * 4 + 1];
|
||||||
expect(g).toBeGreaterThan(200);
|
expect(g).toBeGreaterThan(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('randomColorBlocks', () => {
|
describe("randomColorBlocks", () => {
|
||||||
it('детерминирован по seed и блоки однотонные', () => {
|
it("детерминирован по seed и блоки однотонные", () => {
|
||||||
const a = randomColorBlocks(64, 64, 16, 7);
|
const a = randomColorBlocks(64, 64, 16, 7);
|
||||||
const b = randomColorBlocks(64, 64, 16, 7);
|
const b = randomColorBlocks(64, 64, 16, 7);
|
||||||
expect([...a.data]).toEqual([...b.data]);
|
expect([...a.data]).toEqual([...b.data]);
|
||||||
@@ -27,18 +27,18 @@ describe('randomColorBlocks', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('drawGrid', () => {
|
describe("drawGrid", () => {
|
||||||
it('линии на пересечениях непрозрачны, фон прозрачен', () => {
|
it("линии на пересечениях непрозрачны, фон прозрачен", () => {
|
||||||
const out = drawGrid(100, 100, 4, 4, 2, '#000000', true);
|
const out = drawGrid(100, 100, 4, 4, 2, "#000000", true);
|
||||||
expect(out.data[0]).toBe(0);
|
expect(out.data[0]).toBe(0);
|
||||||
expect(out.data[3]).toBe(255); // (0,0) на линии
|
expect(out.data[3]).toBe(255); // (0,0) на линии
|
||||||
const mid = ((53 * 100) + 53) * 4;
|
const mid = (53 * 100 + 53) * 4;
|
||||||
expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн
|
expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн
|
||||||
});
|
});
|
||||||
|
|
||||||
it('белый непрозрачный фон при transparentBg=false', () => {
|
it("белый непрозрачный фон при transparentBg=false", () => {
|
||||||
const out = drawGrid(20, 20, 2, 2, 1, '#000000', false);
|
const out = drawGrid(20, 20, 2, 2, 1, "#000000", false);
|
||||||
const mid = ((5 * 20) + 5) * 4;
|
const mid = (5 * 20 + 5) * 4;
|
||||||
expect(out.data[mid]).toBe(255);
|
expect(out.data[mid]).toBe(255);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { createPixelImage, type PixelImage } from './types';
|
import { createPixelImage, type PixelImage } from "./types";
|
||||||
import { hslToRgb } from './palette';
|
import { hslToRgb } from "./palette";
|
||||||
import { mulberry32 } from './pixel-fx';
|
import { mulberry32 } from "./pixel-fx";
|
||||||
|
|
||||||
/** Радужный спектр: оттенок 0..360 вдоль выбранной оси. */
|
/** Радужный спектр: оттенок 0..360 вдоль выбранной оси. */
|
||||||
export function colorSpectrum(
|
export function colorSpectrum(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
direction: 'horizontal' | 'vertical',
|
direction: "horizontal" | "vertical",
|
||||||
saturationPercent: number,
|
saturationPercent: number,
|
||||||
lightnessPercent: number
|
lightnessPercent: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const t = direction === 'vertical' ? y / Math.max(1, height - 1) : x / Math.max(1, width - 1);
|
const t =
|
||||||
|
direction === "vertical"
|
||||||
|
? y / Math.max(1, height - 1)
|
||||||
|
: x / Math.max(1, width - 1);
|
||||||
const { r, g, b } = hslToRgb({
|
const { r, g, b } = hslToRgb({
|
||||||
h: t * 360,
|
h: t * 360,
|
||||||
s: saturationPercent / 100,
|
s: saturationPercent / 100,
|
||||||
l: lightnessPercent / 100
|
l: lightnessPercent / 100,
|
||||||
});
|
});
|
||||||
const di = (y * width + x) * 4;
|
const di = (y * width + x) * 4;
|
||||||
out.data[di] = r;
|
out.data[di] = r;
|
||||||
@@ -34,7 +37,7 @@ export function randomColorBlocks(
|
|||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
blockSize: number,
|
blockSize: number,
|
||||||
seed: number
|
seed: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const bs = Math.max(1, Math.round(blockSize));
|
const bs = Math.max(1, Math.round(blockSize));
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
@@ -44,7 +47,7 @@ export function randomColorBlocks(
|
|||||||
const { r, g, b } = hslToRgb({
|
const { r, g, b } = hslToRgb({
|
||||||
h: rng() * 360,
|
h: rng() * 360,
|
||||||
s: 0.65 + rng() * 0.35,
|
s: 0.65 + rng() * 0.35,
|
||||||
l: 0.45 + rng() * 0.25
|
l: 0.45 + rng() * 0.25,
|
||||||
});
|
});
|
||||||
const yMax = Math.min(by + bs, height);
|
const yMax = Math.min(by + bs, height);
|
||||||
const xMax = Math.min(bx + bs, width);
|
const xMax = Math.min(bx + bs, width);
|
||||||
@@ -62,7 +65,11 @@ export function randomColorBlocks(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineMask(length: number, divisions: number, lineWidth: number): Uint8Array {
|
function lineMask(
|
||||||
|
length: number,
|
||||||
|
divisions: number,
|
||||||
|
lineWidth: number,
|
||||||
|
): Uint8Array {
|
||||||
const mask = new Uint8Array(length);
|
const mask = new Uint8Array(length);
|
||||||
const lw = Math.max(1, Math.round(lineWidth));
|
const lw = Math.max(1, Math.round(lineWidth));
|
||||||
for (let i = 0; i <= divisions; i++) {
|
for (let i = 0; i <= divisions; i++) {
|
||||||
@@ -80,7 +87,7 @@ export function drawGrid(
|
|||||||
rows: number,
|
rows: number,
|
||||||
lineWidth: number,
|
lineWidth: number,
|
||||||
colorHex: string,
|
colorHex: string,
|
||||||
transparentBg: boolean
|
transparentBg: boolean,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
if (!transparentBg) out.data.fill(255);
|
if (!transparentBg) out.data.fill(255);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { gradientImage, noiseImage, solidImage } from './generate';
|
import { gradientImage, noiseImage, solidImage } from "./generate";
|
||||||
|
|
||||||
describe('solidImage', () => {
|
describe("solidImage", () => {
|
||||||
it('заливает весь холст заданным цветом', () => {
|
it("заливает весь холст заданным цветом", () => {
|
||||||
const out = solidImage(2, 2, [10, 20, 30, 255]);
|
const out = solidImage(2, 2, [10, 20, 30, 255]);
|
||||||
expect(out.width).toBe(2);
|
expect(out.width).toBe(2);
|
||||||
expect([...out.data]).toEqual(new Array(4).fill([10, 20, 30, 255]).flat());
|
expect([...out.data]).toEqual(new Array(4).fill([10, 20, 30, 255]).flat());
|
||||||
@@ -12,24 +12,26 @@ describe('solidImage', () => {
|
|||||||
[0, 10],
|
[0, 10],
|
||||||
[10, 0],
|
[10, 0],
|
||||||
[2.5, 10],
|
[2.5, 10],
|
||||||
[-1, 5]
|
[-1, 5],
|
||||||
])('бросает ошибку на размерах %i x %i', (w, h) => {
|
])("бросает ошибку на размерах %i x %i", (w, h) => {
|
||||||
expect(() => solidImage(w, h, [0, 0, 0, 255])).toThrow();
|
expect(() => solidImage(w, h, [0, 0, 0, 255])).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('noiseImage', () => {
|
describe("noiseImage", () => {
|
||||||
it('детерминирован: одно зерно — одни байты', () => {
|
it("детерминирован: одно зерно — одни байты", () => {
|
||||||
expect([...noiseImage(4, 4, 42).data]).toEqual([...noiseImage(4, 4, 42).data]);
|
expect([...noiseImage(4, 4, 42).data]).toEqual([
|
||||||
|
...noiseImage(4, 4, 42).data,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('разные зерна дают разные данные', () => {
|
it("разные зерна дают разные данные", () => {
|
||||||
const a = [...noiseImage(8, 8, 1).data];
|
const a = [...noiseImage(8, 8, 1).data];
|
||||||
const b = [...noiseImage(8, 8, 2).data];
|
const b = [...noiseImage(8, 8, 2).data];
|
||||||
expect(a).not.toEqual(b);
|
expect(a).not.toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('альфа всегда непрозрачная', () => {
|
it("альфа всегда непрозрачная", () => {
|
||||||
const data = noiseImage(3, 3, 7).data;
|
const data = noiseImage(3, 3, 7).data;
|
||||||
for (let i = 3; i < data.length; i += 4) {
|
for (let i = 3; i < data.length; i += 4) {
|
||||||
expect(data[i]).toBe(255);
|
expect(data[i]).toBe(255);
|
||||||
@@ -37,14 +39,14 @@ describe('noiseImage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('gradientImage', () => {
|
describe("gradientImage", () => {
|
||||||
it('горизонтальный градиент идёт от цвета A к цвету B', () => {
|
it("горизонтальный градиент идёт от цвета A к цвету B", () => {
|
||||||
const out = gradientImage(
|
const out = gradientImage(
|
||||||
3,
|
3,
|
||||||
1,
|
1,
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[255, 255, 255, 255],
|
[255, 255, 255, 255],
|
||||||
'horizontal'
|
"horizontal",
|
||||||
);
|
);
|
||||||
const px = (x: number) => [...out.data.slice(x * 4, x * 4 + 4)];
|
const px = (x: number) => [...out.data.slice(x * 4, x * 4 + 4)];
|
||||||
expect(px(0)).toEqual([0, 0, 0, 255]);
|
expect(px(0)).toEqual([0, 0, 0, 255]);
|
||||||
@@ -52,13 +54,13 @@ describe('gradientImage', () => {
|
|||||||
expect(px(2)).toEqual([255, 255, 255, 255]);
|
expect(px(2)).toEqual([255, 255, 255, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('вертикальный градиент меняется по строкам', () => {
|
it("вертикальный градиент меняется по строкам", () => {
|
||||||
const out = gradientImage(
|
const out = gradientImage(
|
||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[100, 100, 100, 255],
|
[100, 100, 100, 255],
|
||||||
'vertical'
|
"vertical",
|
||||||
);
|
);
|
||||||
expect(out.data[0]).toBe(0);
|
expect(out.data[0]).toBe(0);
|
||||||
expect(out.data[4]).toBe(100);
|
expect(out.data[4]).toBe(100);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from "./types";
|
||||||
import { ToolError } from './errors';
|
import { ToolError } from "./errors";
|
||||||
|
|
||||||
export function solidImage(
|
export function solidImage(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
rgba: [number, number, number, number]
|
rgba: [number, number, number, number],
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (
|
||||||
throw new ToolError('errors.sizeInt');
|
!Number.isInteger(width) ||
|
||||||
|
!Number.isInteger(height) ||
|
||||||
|
width < 1 ||
|
||||||
|
height < 1
|
||||||
|
) {
|
||||||
|
throw new ToolError("errors.sizeInt");
|
||||||
}
|
}
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
@@ -19,9 +24,18 @@ export function solidImage(
|
|||||||
return { width, height, data };
|
return { width, height, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function noiseImage(width: number, height: number, seed: number): PixelImage {
|
export function noiseImage(
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
width: number,
|
||||||
throw new ToolError('errors.sizeInt');
|
height: number,
|
||||||
|
seed: number,
|
||||||
|
): PixelImage {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(width) ||
|
||||||
|
!Number.isInteger(height) ||
|
||||||
|
width < 1 ||
|
||||||
|
height < 1
|
||||||
|
) {
|
||||||
|
throw new ToolError("errors.sizeInt");
|
||||||
}
|
}
|
||||||
const random = mulberry32(seed);
|
const random = mulberry32(seed);
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
@@ -39,20 +53,25 @@ export function gradientImage(
|
|||||||
height: number,
|
height: number,
|
||||||
fromRgba: [number, number, number, number],
|
fromRgba: [number, number, number, number],
|
||||||
toRgba: [number, number, number, number],
|
toRgba: [number, number, number, number],
|
||||||
direction: 'horizontal' | 'vertical'
|
direction: "horizontal" | "vertical",
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
if (
|
||||||
throw new ToolError('errors.sizeInt');
|
!Number.isInteger(width) ||
|
||||||
|
!Number.isInteger(height) ||
|
||||||
|
width < 1 ||
|
||||||
|
height < 1
|
||||||
|
) {
|
||||||
|
throw new ToolError("errors.sizeInt");
|
||||||
}
|
}
|
||||||
const out: PixelImage = {
|
const out: PixelImage = {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
data: new Uint8ClampedArray(width * height * 4)
|
data: new Uint8ClampedArray(width * height * 4),
|
||||||
};
|
};
|
||||||
const steps = (direction === 'horizontal' ? width : height) - 1;
|
const steps = (direction === "horizontal" ? width : height) - 1;
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const t = steps === 0 ? 0 : (direction === 'horizontal' ? x : y) / steps;
|
const t = steps === 0 ? 0 : (direction === "horizontal" ? x : y) / steps;
|
||||||
const i = (y * width + x) * 4;
|
const i = (y * width + x) * 4;
|
||||||
out.data[i] = fromRgba[0] + (toRgba[0] - fromRgba[0]) * t;
|
out.data[i] = fromRgba[0] + (toRgba[0] - fromRgba[0]) * t;
|
||||||
out.data[i + 1] = fromRgba[1] + (toRgba[1] - fromRgba[1]) * t;
|
out.data[i + 1] = fromRgba[1] + (toRgba[1] - fromRgba[1]) * t;
|
||||||
|
|||||||
@@ -1,94 +1,90 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './geometry';
|
import {
|
||||||
import { makeImage } from './test-helpers';
|
centerByAlpha,
|
||||||
|
crop,
|
||||||
|
expandCanvas,
|
||||||
|
flip,
|
||||||
|
resize,
|
||||||
|
rotate90,
|
||||||
|
tile,
|
||||||
|
} from "./geometry";
|
||||||
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
const square = () =>
|
const square = () =>
|
||||||
makeImage(2, 2, [
|
makeImage(2, 2, [
|
||||||
[1, 1, 1, 1],
|
[1, 1, 1, 1],
|
||||||
[2, 2, 2, 2],
|
[2, 2, 2, 2],
|
||||||
[3, 3, 3, 3],
|
[3, 3, 3, 3],
|
||||||
[4, 4, 4, 4]
|
[4, 4, 4, 4],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
describe('flip', () => {
|
describe("flip", () => {
|
||||||
it('отражает по горизонтали (зеркало слева-направо)', () => {
|
it("отражает по горизонтали (зеркало слева-направо)", () => {
|
||||||
const out = flip(square(), 'horizontal');
|
const out = flip(square(), "horizontal");
|
||||||
expect(out.width).toBe(2);
|
expect(out.width).toBe(2);
|
||||||
expect(out.height).toBe(2);
|
expect(out.height).toBe(2);
|
||||||
expect([...out.data]).toEqual([
|
expect([...out.data]).toEqual([
|
||||||
2, 2, 2, 2,
|
2, 2, 2, 2, 1, 1, 1, 1, 4, 4, 4, 4, 3, 3, 3, 3,
|
||||||
1, 1, 1, 1,
|
|
||||||
4, 4, 4, 4,
|
|
||||||
3, 3, 3, 3
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('отражает по вертикали (сверху-вниз)', () => {
|
it("отражает по вертикали (сверху-вниз)", () => {
|
||||||
const out = flip(square(), 'vertical');
|
const out = flip(square(), "vertical");
|
||||||
expect([...out.data]).toEqual([
|
expect([...out.data]).toEqual([
|
||||||
3, 3, 3, 3,
|
3, 3, 3, 3, 4, 4, 4, 4, 1, 1, 1, 1, 2, 2, 2, 2,
|
||||||
4, 4, 4, 4,
|
|
||||||
1, 1, 1, 1,
|
|
||||||
2, 2, 2, 2
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('не мутирует вход', () => {
|
it("не мутирует вход", () => {
|
||||||
const img = square();
|
const img = square();
|
||||||
flip(img, 'horizontal');
|
flip(img, "horizontal");
|
||||||
expect([...img.data]).toEqual([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]);
|
expect([...img.data]).toEqual([
|
||||||
|
1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rotate90', () => {
|
describe("rotate90", () => {
|
||||||
it('поворачивает неквадрат 2x3 на 90° по часовой', () => {
|
it("поворачивает неквадрат 2x3 на 90° по часовой", () => {
|
||||||
const rect = makeImage(2, 3, [
|
const rect = makeImage(2, 3, [
|
||||||
[1, 1, 1, 1],
|
[1, 1, 1, 1],
|
||||||
[2, 2, 2, 2],
|
[2, 2, 2, 2],
|
||||||
[3, 3, 3, 3],
|
[3, 3, 3, 3],
|
||||||
[4, 4, 4, 4],
|
[4, 4, 4, 4],
|
||||||
[5, 5, 5, 5],
|
[5, 5, 5, 5],
|
||||||
[6, 6, 6, 6]
|
[6, 6, 6, 6],
|
||||||
]);
|
]);
|
||||||
const out = rotate90(rect, 1);
|
const out = rotate90(rect, 1);
|
||||||
expect(out.width).toBe(3);
|
expect(out.width).toBe(3);
|
||||||
expect(out.height).toBe(2);
|
expect(out.height).toBe(2);
|
||||||
expect([...out.data]).toEqual([
|
expect([...out.data]).toEqual([
|
||||||
5, 5, 5, 5,
|
5, 5, 5, 5, 3, 3, 3, 3, 1, 1, 1, 1, 6, 6, 6, 6, 4, 4, 4, 4, 2, 2, 2, 2,
|
||||||
3, 3, 3, 3,
|
|
||||||
1, 1, 1, 1,
|
|
||||||
6, 6, 6, 6,
|
|
||||||
4, 4, 4, 4,
|
|
||||||
2, 2, 2, 2
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('turns=0 возвращает копию без изменений', () => {
|
it("turns=0 возвращает копию без изменений", () => {
|
||||||
const img = square();
|
const img = square();
|
||||||
const out = rotate90(img, 0);
|
const out = rotate90(img, 0);
|
||||||
expect(out).not.toBe(img);
|
expect(out).not.toBe(img);
|
||||||
expect([...out.data]).toEqual([...img.data]);
|
expect([...out.data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('нормализует turns: 5 ≡ 1, -3 ≡ 1, 4 ≡ 0', () => {
|
it("нормализует turns: 5 ≡ 1, -3 ≡ 1, 4 ≡ 0", () => {
|
||||||
const once = [...rotate90(square(), 1).data];
|
const once = [...rotate90(square(), 1).data];
|
||||||
expect([...rotate90(square(), 5).data]).toEqual(once);
|
expect([...rotate90(square(), 5).data]).toEqual(once);
|
||||||
expect([...rotate90(square(), -3).data]).toEqual(once);
|
expect([...rotate90(square(), -3).data]).toEqual(once);
|
||||||
expect([...rotate90(square(), 4).data]).toEqual([...square().data]);
|
expect([...rotate90(square(), 4).data]).toEqual([...square().data]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('turns=2 на квадрате равно двойному отражению', () => {
|
it("turns=2 на квадрате равно двойному отражению", () => {
|
||||||
const out = rotate90(square(), 2);
|
const out = rotate90(square(), 2);
|
||||||
expect([...out.data]).toEqual([
|
expect([...out.data]).toEqual([
|
||||||
4, 4, 4, 4,
|
4, 4, 4, 4, 3, 3, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1,
|
||||||
3, 3, 3, 3,
|
|
||||||
2, 2, 2, 2,
|
|
||||||
1, 1, 1, 1
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('crop', () => {
|
describe("crop", () => {
|
||||||
const grid = () =>
|
const grid = () =>
|
||||||
makeImage(3, 3, [
|
makeImage(3, 3, [
|
||||||
[1, 1, 1, 1],
|
[1, 1, 1, 1],
|
||||||
@@ -99,63 +95,64 @@ describe('crop', () => {
|
|||||||
[6, 6, 6, 6],
|
[6, 6, 6, 6],
|
||||||
[7, 7, 7, 7],
|
[7, 7, 7, 7],
|
||||||
[8, 8, 8, 8],
|
[8, 8, 8, 8],
|
||||||
[9, 9, 9, 9]
|
[9, 9, 9, 9],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
it('вырезает центральную область 2x2 из 3x3', () => {
|
it("вырезает центральную область 2x2 из 3x3", () => {
|
||||||
const out = crop(grid(), 1, 1, 2, 2);
|
const out = crop(grid(), 1, 1, 2, 2);
|
||||||
expect(out.width).toBe(2);
|
expect(out.width).toBe(2);
|
||||||
expect(out.height).toBe(2);
|
expect(out.height).toBe(2);
|
||||||
expect([...out.data]).toEqual([
|
expect([...out.data]).toEqual([
|
||||||
5, 5, 5, 5,
|
5, 5, 5, 5, 6, 6, 6, 6, 8, 8, 8, 8, 9, 9, 9, 9,
|
||||||
6, 6, 6, 6,
|
|
||||||
8, 8, 8, 8,
|
|
||||||
9, 9, 9, 9
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('усекает область, выходящую за границы', () => {
|
it("усекает область, выходящую за границы", () => {
|
||||||
const out = crop(grid(), -1, -1, 2, 2);
|
const out = crop(grid(), -1, -1, 2, 2);
|
||||||
expect(out.width).toBe(1);
|
expect(out.width).toBe(1);
|
||||||
expect(out.height).toBe(1);
|
expect(out.height).toBe(1);
|
||||||
expect([...out.data]).toEqual([1, 1, 1, 1]);
|
expect([...out.data]).toEqual([1, 1, 1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('бросает ToolError для области вне изображения', () => {
|
it("бросает ToolError для области вне изображения", () => {
|
||||||
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(/errors\.cropBounds/);
|
expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(/errors\.cropBounds/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('expandCanvas', () => {
|
describe("expandCanvas", () => {
|
||||||
const pixel = () => makeImage(1, 1, [[10, 20, 30, 255]]);
|
const pixel = () => makeImage(1, 1, [[10, 20, 30, 255]]);
|
||||||
|
|
||||||
it('прозрачное расширение кладёт пиксель со смещением', () => {
|
it("прозрачное расширение кладёт пиксель со смещением", () => {
|
||||||
const out = expandCanvas(pixel(), 1, 2, 3, 4);
|
const out = expandCanvas(pixel(), 1, 2, 3, 4);
|
||||||
expect(out.width).toBe(5);
|
expect(out.width).toBe(5);
|
||||||
expect(out.height).toBe(7);
|
expect(out.height).toBe(7);
|
||||||
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 0]);
|
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 0]);
|
||||||
expect([...out.data.slice((2 * 5 + 1) * 4, (2 * 5 + 1) * 4 + 4)]).toEqual([10, 20, 30, 255]);
|
expect([...out.data.slice((2 * 5 + 1) * 4, (2 * 5 + 1) * 4 + 4)]).toEqual([
|
||||||
|
10, 20, 30, 255,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('цветной фон заливает всё вокруг', () => {
|
it("цветной фон заливает всё вокруг", () => {
|
||||||
const out = expandCanvas(pixel(), 1, 0, 0, 0, '#ffffff');
|
const out = expandCanvas(pixel(), 1, 0, 0, 0, "#ffffff");
|
||||||
expect(out.data[0]).toBe(255);
|
expect(out.data[0]).toBe(255);
|
||||||
expect(out.data[3]).toBe(255);
|
expect(out.data[3]).toBe(255);
|
||||||
expect(out.data[(0 * 2 + 1) * 4 + 3]).toBe(255);
|
expect(out.data[(0 * 2 + 1) * 4 + 3]).toBe(255);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('tile', () => {
|
describe("tile", () => {
|
||||||
it('повторяет изображение по сетке', () => {
|
it("повторяет изображение по сетке", () => {
|
||||||
const out = tile(makeImage(1, 1, [[9, 9, 9, 255]]), 3, 2);
|
const out = tile(makeImage(1, 1, [[9, 9, 9, 255]]), 3, 2);
|
||||||
expect(out.width).toBe(3);
|
expect(out.width).toBe(3);
|
||||||
expect(out.height).toBe(2);
|
expect(out.height).toBe(2);
|
||||||
expect([...out.data].filter((_, i) => i % 4 === 0)).toEqual([9, 9, 9, 9, 9, 9]);
|
expect([...out.data].filter((_, i) => i % 4 === 0)).toEqual([
|
||||||
|
9, 9, 9, 9, 9, 9,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('centerByAlpha', () => {
|
describe("centerByAlpha", () => {
|
||||||
it('вырезает непрозрачный блок и центрирует на прежнем холсте', () => {
|
it("вырезает непрозрачный блок и центрирует на прежнем холсте", () => {
|
||||||
const img = makeImage(3, 3, [
|
const img = makeImage(3, 3, [
|
||||||
[0, 0, 0, 0],
|
[0, 0, 0, 0],
|
||||||
[0, 0, 0, 0],
|
[0, 0, 0, 0],
|
||||||
@@ -165,7 +162,7 @@ describe('centerByAlpha', () => {
|
|||||||
[0, 0, 0, 0],
|
[0, 0, 0, 0],
|
||||||
[0, 0, 0, 0],
|
[0, 0, 0, 0],
|
||||||
[0, 0, 0, 0],
|
[0, 0, 0, 0],
|
||||||
[0, 0, 0, 0]
|
[0, 0, 0, 0],
|
||||||
]);
|
]);
|
||||||
const out = centerByAlpha(img);
|
const out = centerByAlpha(img);
|
||||||
expect(out.width).toBe(3);
|
expect(out.width).toBe(3);
|
||||||
@@ -175,30 +172,30 @@ describe('centerByAlpha', () => {
|
|||||||
expect(alphaAt(1, 1)).toBe(255);
|
expect(alphaAt(1, 1)).toBe(255);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('полностью прозрачное изображение возвращается без изменений', () => {
|
it("полностью прозрачное изображение возвращается без изменений", () => {
|
||||||
const img = makeImage(2, 1, [
|
const img = makeImage(2, 1, [
|
||||||
[0, 0, 0, 0],
|
[0, 0, 0, 0],
|
||||||
[0, 0, 0, 0]
|
[0, 0, 0, 0],
|
||||||
]);
|
]);
|
||||||
expect([...centerByAlpha(img).data]).toEqual([...img.data]);
|
expect([...centerByAlpha(img).data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resize', () => {
|
describe("resize", () => {
|
||||||
const twoByTwo = () =>
|
const twoByTwo = () =>
|
||||||
makeImage(2, 2, [
|
makeImage(2, 2, [
|
||||||
[0, 0, 0, 255],
|
[0, 0, 0, 255],
|
||||||
[100, 100, 100, 255],
|
[100, 100, 100, 255],
|
||||||
[200, 200, 200, 255],
|
[200, 200, 200, 255],
|
||||||
[255, 255, 255, 255]
|
[255, 255, 255, 255],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
it('совпадает с входом при тех же размерах', () => {
|
it("совпадает с входом при тех же размерах", () => {
|
||||||
const img = twoByTwo();
|
const img = twoByTwo();
|
||||||
expect([...resize(img, 2, 2).data]).toEqual([...img.data]);
|
expect([...resize(img, 2, 2).data]).toEqual([...img.data]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('апскейл 2x2 -> 4x4 билинейно интерполирует', () => {
|
it("апскейл 2x2 -> 4x4 билинейно интерполирует", () => {
|
||||||
const out = resize(twoByTwo(), 4, 4);
|
const out = resize(twoByTwo(), 4, 4);
|
||||||
expect(out.width).toBe(4);
|
expect(out.width).toBe(4);
|
||||||
expect(out.height).toBe(4);
|
expect(out.height).toBe(4);
|
||||||
@@ -207,7 +204,7 @@ describe('resize', () => {
|
|||||||
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
|
expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('бросает ToolError на некорректные размеры', () => {
|
it("бросает ToolError на некорректные размеры", () => {
|
||||||
const img = twoByTwo();
|
const img = twoByTwo();
|
||||||
expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/);
|
expect(() => resize(img, 0, 10)).toThrow(/errors\.sizeInt/);
|
||||||
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
|
expect(() => resize(img, 10.5, 10)).toThrow(/errors\.sizeInt/);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { parseHex } from './alpha';
|
import { parseHex } from "./alpha";
|
||||||
import { ToolError } from './errors';
|
import { ToolError } from "./errors";
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
|
||||||
|
|
||||||
export function expandCanvas(
|
export function expandCanvas(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
@@ -8,7 +8,7 @@ export function expandCanvas(
|
|||||||
top: number,
|
top: number,
|
||||||
right: number,
|
right: number,
|
||||||
bottom: number,
|
bottom: number,
|
||||||
backgroundHex?: string
|
backgroundHex?: string,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const l = Math.max(0, Math.trunc(left));
|
const l = Math.max(0, Math.trunc(left));
|
||||||
const t = Math.max(0, Math.trunc(top));
|
const t = Math.max(0, Math.trunc(top));
|
||||||
@@ -28,13 +28,17 @@ export function expandCanvas(
|
|||||||
const srcStart = y * img.width * 4;
|
const srcStart = y * img.width * 4;
|
||||||
out.data.set(
|
out.data.set(
|
||||||
img.data.subarray(srcStart, srcStart + img.width * 4),
|
img.data.subarray(srcStart, srcStart + img.width * 4),
|
||||||
((y + t) * out.width + l) * 4
|
((y + t) * out.width + l) * 4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tile(img: PixelImage, columns: number, rows: number): PixelImage {
|
export function tile(
|
||||||
|
img: PixelImage,
|
||||||
|
columns: number,
|
||||||
|
rows: number,
|
||||||
|
): PixelImage {
|
||||||
const cols = Math.max(1, Math.trunc(columns));
|
const cols = Math.max(1, Math.trunc(columns));
|
||||||
const rowsCount = Math.max(1, Math.trunc(rows));
|
const rowsCount = Math.max(1, Math.trunc(rows));
|
||||||
const out = createPixelImage(img.width * cols, img.height * rowsCount);
|
const out = createPixelImage(img.width * cols, img.height * rowsCount);
|
||||||
@@ -44,7 +48,7 @@ export function tile(img: PixelImage, columns: number, rows: number): PixelImage
|
|||||||
const srcStart = y * img.width * 4;
|
const srcStart = y * img.width * 4;
|
||||||
out.data.set(
|
out.data.set(
|
||||||
img.data.subarray(srcStart, srcStart + img.width * 4),
|
img.data.subarray(srcStart, srcStart + img.width * 4),
|
||||||
((ty * img.height + y) * out.width + tx * img.width) * 4
|
((ty * img.height + y) * out.width + tx * img.width) * 4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,20 +79,20 @@ export function centerByAlpha(img: PixelImage): PixelImage {
|
|||||||
for (let y = 0; y < content.height; y++) {
|
for (let y = 0; y < content.height; y++) {
|
||||||
out.data.set(
|
out.data.set(
|
||||||
content.data.subarray(y * content.width * 4, (y + 1) * content.width * 4),
|
content.data.subarray(y * content.width * 4, (y + 1) * content.width * 4),
|
||||||
((y + dy) * out.width + dx) * 4
|
((y + dy) * out.width + dx) * 4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlipAxis = 'horizontal' | 'vertical';
|
export type FlipAxis = "horizontal" | "vertical";
|
||||||
|
|
||||||
export function flip(img: PixelImage, axis: FlipAxis): PixelImage {
|
export function flip(img: PixelImage, axis: FlipAxis): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let y = 0; y < img.height; y++) {
|
for (let y = 0; y < img.height; y++) {
|
||||||
for (let x = 0; x < img.width; x++) {
|
for (let x = 0; x < img.width; x++) {
|
||||||
const sx = axis === 'horizontal' ? img.width - 1 - x : x;
|
const sx = axis === "horizontal" ? img.width - 1 - x : x;
|
||||||
const sy = axis === 'vertical' ? img.height - 1 - y : y;
|
const sy = axis === "vertical" ? img.height - 1 - y : y;
|
||||||
copyPixel(img, sx, sy, out, x, y);
|
copyPixel(img, sx, sy, out, x, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +124,7 @@ export function crop(
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
width: number,
|
width: number,
|
||||||
height: number
|
height: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const sx = clampInt(x, 0, img.width);
|
const sx = clampInt(x, 0, img.width);
|
||||||
const sy = clampInt(y, 0, img.height);
|
const sy = clampInt(y, 0, img.height);
|
||||||
@@ -129,7 +133,7 @@ export function crop(
|
|||||||
const w = ex - sx;
|
const w = ex - sx;
|
||||||
const h = ey - sy;
|
const h = ey - sy;
|
||||||
if (w <= 0 || h <= 0) {
|
if (w <= 0 || h <= 0) {
|
||||||
throw new ToolError('errors.cropBounds');
|
throw new ToolError("errors.cropBounds");
|
||||||
}
|
}
|
||||||
const out = createPixelImage(w, h);
|
const out = createPixelImage(w, h);
|
||||||
for (let row = 0; row < h; row++) {
|
for (let row = 0; row < h; row++) {
|
||||||
@@ -139,9 +143,18 @@ export function crop(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resize(img: PixelImage, width: number, height: number): PixelImage {
|
export function resize(
|
||||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
img: PixelImage,
|
||||||
throw new ToolError('errors.sizeInt');
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): PixelImage {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(width) ||
|
||||||
|
!Number.isInteger(height) ||
|
||||||
|
width < 1 ||
|
||||||
|
height < 1
|
||||||
|
) {
|
||||||
|
throw new ToolError("errors.sizeInt");
|
||||||
}
|
}
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
const xr = img.width / width;
|
const xr = img.width / width;
|
||||||
@@ -173,7 +186,14 @@ export function resize(img: PixelImage, width: number, height: number): PixelIma
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyPixel(src: PixelImage, sx: number, sy: number, dst: PixelImage, dx: number, dy: number): void {
|
function copyPixel(
|
||||||
|
src: PixelImage,
|
||||||
|
sx: number,
|
||||||
|
sy: number,
|
||||||
|
dst: PixelImage,
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
): void {
|
||||||
const si = (sy * src.width + sx) * 4;
|
const si = (sy * src.width + sx) * 4;
|
||||||
const di = (dy * dst.width + dx) * 4;
|
const di = (dy * dst.width + dx) * 4;
|
||||||
dst.data[di] = src.data[si];
|
dst.data[di] = src.data[si];
|
||||||
@@ -185,7 +205,7 @@ function copyPixel(src: PixelImage, sx: number, sy: number, dst: PixelImage, dx:
|
|||||||
export function sampleBilinear(
|
export function sampleBilinear(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
fx: number,
|
fx: number,
|
||||||
fy: number
|
fy: number,
|
||||||
): [number, number, number, number] {
|
): [number, number, number, number] {
|
||||||
const maxX = img.width - 1;
|
const maxX = img.width - 1;
|
||||||
const maxY = img.height - 1;
|
const maxY = img.height - 1;
|
||||||
@@ -214,23 +234,21 @@ function clampInt(value: number, min: number, max: number): number {
|
|||||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type Anchor9 =
|
export type Anchor9 =
|
||||||
| 'top-left'
|
| "top-left"
|
||||||
| 'top-center'
|
| "top-center"
|
||||||
| 'top-right'
|
| "top-right"
|
||||||
| 'middle-left'
|
| "middle-left"
|
||||||
| 'center'
|
| "center"
|
||||||
| 'middle-right'
|
| "middle-right"
|
||||||
| 'bottom-left'
|
| "bottom-left"
|
||||||
| 'bottom-center'
|
| "bottom-center"
|
||||||
| 'bottom-right';
|
| "bottom-right";
|
||||||
|
|
||||||
/** Границы контента: пиксели с альфой строго больше порога. Пустое изображение → null. */
|
/** Границы контента: пиксели с альфой строго больше порога. Пустое изображение → null. */
|
||||||
export function contentBounds(
|
export function contentBounds(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
alphaThreshold = 0
|
alphaThreshold = 0,
|
||||||
): { x: number; y: number; w: number; h: number } | null {
|
): { x: number; y: number; w: number; h: number } | null {
|
||||||
let minX = img.width;
|
let minX = img.width;
|
||||||
let minY = img.height;
|
let minY = img.height;
|
||||||
@@ -262,11 +280,19 @@ export function changeCanvasSize(
|
|||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
anchor: Anchor9
|
anchor: Anchor9,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
const pasteX = anchor.endsWith('-left') ? 0 : anchor.endsWith('-right') ? width - img.width : Math.floor((width - img.width) / 2);
|
const pasteX = anchor.endsWith("-left")
|
||||||
const pasteY = anchor.startsWith('top-') ? 0 : anchor.startsWith('bottom-') ? height - img.height : Math.floor((height - img.height) / 2);
|
? 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++) {
|
for (let y = 0; y < height; y++) {
|
||||||
const sy = y - pasteY;
|
const sy = y - pasteY;
|
||||||
if (sy < 0 || sy >= img.height) continue;
|
if (sy < 0 || sy >= img.height) continue;
|
||||||
@@ -307,13 +333,21 @@ export function padToRatio(img: PixelImage, ratio: number): PixelImage {
|
|||||||
else if (current < ratio) w = Math.round(h * ratio);
|
else if (current < ratio) w = Math.round(h * ratio);
|
||||||
w = Math.max(1, w);
|
w = Math.max(1, w);
|
||||||
h = Math.max(1, h);
|
h = Math.max(1, h);
|
||||||
return changeCanvasSize(img, w, h, 'center');
|
return changeCanvasSize(img, w, h, "center");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Разворачивает изображение на 90°, если его ориентация не совпадает с целевой. Квадрат не трогает. */
|
/** Разворачивает изображение на 90°, если его ориентация не совпадает с целевой. Квадрат не трогает. */
|
||||||
export function forceOrientation(img: PixelImage, target: 'portrait' | 'landscape'): PixelImage {
|
export function forceOrientation(
|
||||||
const current = img.width > img.height ? 'landscape' : img.width < img.height ? 'portrait' : 'square';
|
img: PixelImage,
|
||||||
if (current === target || current === 'square') return clonePixelImage(img);
|
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);
|
return rotate90(img, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,14 +357,14 @@ export function forceOrientation(img: PixelImage, target: 'portrait' | 'landscap
|
|||||||
*/
|
*/
|
||||||
export function symmetricCopy(
|
export function symmetricCopy(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
axis: 'vertical' | 'horizontal',
|
axis: "vertical" | "horizontal",
|
||||||
keepSide: 'left' | 'right' | 'top' | 'bottom'
|
keepSide: "left" | "right" | "top" | "bottom",
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
if (axis === 'vertical') {
|
if (axis === "vertical") {
|
||||||
const out = createPixelImage(img.width * 2, img.height);
|
const out = createPixelImage(img.width * 2, img.height);
|
||||||
for (let y = 0; y < img.height; y++) {
|
for (let y = 0; y < img.height; y++) {
|
||||||
for (let x = 0; x < img.width; x++) {
|
for (let x = 0; x < img.width; x++) {
|
||||||
const srcX = keepSide === 'left' ? x : img.width - 1 - x;
|
const srcX = keepSide === "left" ? x : img.width - 1 - x;
|
||||||
const di = (y * out.width + x) * 4;
|
const di = (y * out.width + x) * 4;
|
||||||
const si = (y * img.width + srcX) * 4;
|
const si = (y * img.width + srcX) * 4;
|
||||||
out.data[di] = img.data[si];
|
out.data[di] = img.data[si];
|
||||||
@@ -349,7 +383,7 @@ export function symmetricCopy(
|
|||||||
}
|
}
|
||||||
const out = createPixelImage(img.width, img.height * 2);
|
const out = createPixelImage(img.width, img.height * 2);
|
||||||
for (let y = 0; y < img.height; y++) {
|
for (let y = 0; y < img.height; y++) {
|
||||||
const srcY = keepSide === 'top' ? y : img.height - 1 - y;
|
const srcY = keepSide === "top" ? y : img.height - 1 - y;
|
||||||
for (let x = 0; x < img.width; x++) {
|
for (let x = 0; x < img.width; x++) {
|
||||||
const si = (srcY * img.width + x) * 4;
|
const si = (srcY * img.width + x) * 4;
|
||||||
const dTop = (y * out.width + x) * 4;
|
const dTop = (y * out.width + x) * 4;
|
||||||
|
|||||||
+30
-22
@@ -1,32 +1,40 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { isSupportedImage, unsupportedImageError } from './io';
|
import { isSupportedImage, unsupportedImageError } from "./io";
|
||||||
|
|
||||||
describe('isSupportedImage', () => {
|
describe("isSupportedImage", () => {
|
||||||
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon'])(
|
it.each([
|
||||||
'принимает %s',
|
"image/png",
|
||||||
(type) => {
|
"image/jpeg",
|
||||||
expect(isSupportedImage(new File([], 'x', { type }))).toBe(true);
|
"image/webp",
|
||||||
}
|
"image/gif",
|
||||||
);
|
"image/bmp",
|
||||||
|
"image/x-icon",
|
||||||
it('отклоняет неподдерживаемый тип', () => {
|
])("принимает %s", (type) => {
|
||||||
expect(isSupportedImage(new File([], 'a.txt', { type: 'text/plain' }))).toBe(false);
|
expect(isSupportedImage(new File([], "x", { type }))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('отклоняет файл без типа', () => {
|
it("отклоняет неподдерживаемый тип", () => {
|
||||||
expect(isSupportedImage(new File([], 'x'))).toBe(false);
|
expect(
|
||||||
|
isSupportedImage(new File([], "a.txt", { type: "text/plain" })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("отклоняет файл без типа", () => {
|
||||||
|
expect(isSupportedImage(new File([], "x"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unsupportedImageError', () => {
|
describe("unsupportedImageError", () => {
|
||||||
it('ключ ошибки и тип файла в vars', () => {
|
it("ключ ошибки и тип файла в vars", () => {
|
||||||
const err = unsupportedImageError(new File([], 'a.txt', { type: 'text/plain' }));
|
const err = unsupportedImageError(
|
||||||
expect(err.key).toBe('errors.unsupportedFile');
|
new File([], "a.txt", { type: "text/plain" }),
|
||||||
expect(err.vars?.type).toBe('text/plain');
|
);
|
||||||
|
expect(err.key).toBe("errors.unsupportedFile");
|
||||||
|
expect(err.vars?.type).toBe("text/plain");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('пустой тип передаётся как unknown', () => {
|
it("пустой тип передаётся как unknown", () => {
|
||||||
const err = unsupportedImageError(new File([], 'x'));
|
const err = unsupportedImageError(new File([], "x"));
|
||||||
expect(err.vars?.type).toBe('unknown');
|
expect(err.vars?.type).toBe("unknown");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+67
-43
@@ -1,33 +1,40 @@
|
|||||||
import { encodeBmpBytes } from './bmp';
|
import { encodeBmpBytes } from "./bmp";
|
||||||
import { ToolError } from './errors';
|
import { ToolError } from "./errors";
|
||||||
import { type PixelImage } from './types';
|
import { type PixelImage } from "./types";
|
||||||
|
|
||||||
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
|
export type OutputMime =
|
||||||
|
"image/png" | "image/jpeg" | "image/webp" | "image/bmp";
|
||||||
|
|
||||||
export const ACCEPTED_IMAGE_TYPES =
|
export const ACCEPTED_IMAGE_TYPES =
|
||||||
'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon';
|
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon";
|
||||||
|
|
||||||
const SUPPORTED_MIME_TYPES = new Set(ACCEPTED_IMAGE_TYPES.split(','));
|
const SUPPORTED_MIME_TYPES = new Set(ACCEPTED_IMAGE_TYPES.split(","));
|
||||||
|
|
||||||
export function isSupportedImage(file: File): boolean {
|
export function isSupportedImage(file: File): boolean {
|
||||||
return SUPPORTED_MIME_TYPES.has(file.type);
|
return SUPPORTED_MIME_TYPES.has(file.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unsupportedImageError(file: File): ToolError {
|
export function unsupportedImageError(file: File): ToolError {
|
||||||
return new ToolError('errors.unsupportedFile', { type: file.type || 'unknown' });
|
return new ToolError("errors.unsupportedFile", {
|
||||||
|
type: file.type || "unknown",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = bitmap.width;
|
canvas.width = bitmap.width;
|
||||||
canvas.height = bitmap.height;
|
canvas.height = bitmap.height;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new ToolError('errors.noCanvasCtx');
|
throw new ToolError("errors.noCanvasCtx");
|
||||||
}
|
}
|
||||||
ctx.drawImage(bitmap, 0, 0);
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
return {
|
||||||
|
width: imageData.width,
|
||||||
|
height: imageData.height,
|
||||||
|
data: imageData.data,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decodeFile(file: File): Promise<PixelImage> {
|
export async function decodeFile(file: File): Promise<PixelImage> {
|
||||||
@@ -39,7 +46,9 @@ export async function decodeFile(file: File): Promise<PixelImage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decodeBytes(bytes: Uint8Array<ArrayBuffer>): Promise<PixelImage> {
|
export async function decodeBytes(
|
||||||
|
bytes: Uint8Array<ArrayBuffer>,
|
||||||
|
): Promise<PixelImage> {
|
||||||
const blob = new Blob([bytes]);
|
const blob = new Blob([bytes]);
|
||||||
const bitmap = await createImageBitmap(blob);
|
const bitmap = await createImageBitmap(blob);
|
||||||
try {
|
try {
|
||||||
@@ -50,25 +59,25 @@ export async function decodeBytes(bytes: Uint8Array<ArrayBuffer>): Promise<Pixel
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toDataUrl(img: PixelImage): string {
|
export function toDataUrl(img: PixelImage): string {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = img.width;
|
canvas.width = img.width;
|
||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new ToolError('errors.noCanvasCtx');
|
throw new ToolError("errors.noCanvasCtx");
|
||||||
}
|
}
|
||||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||||
return canvas.toDataURL('image/png');
|
return canvas.toDataURL("image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBase64(img: PixelImage): string {
|
export function toBase64(img: PixelImage): string {
|
||||||
return toDataUrl(img).slice('data:image/png;base64,'.length);
|
return toDataUrl(img).slice("data:image/png;base64,".length);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decodeTextImage(text: string): Promise<PixelImage> {
|
export async function decodeTextImage(text: string): Promise<PixelImage> {
|
||||||
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
|
const cleaned = text.trim().replace(/^data:[^,]*,/, "");
|
||||||
if (cleaned.length === 0) {
|
if (cleaned.length === 0) {
|
||||||
throw new ToolError('errors.badBase64');
|
throw new ToolError("errors.badBase64");
|
||||||
}
|
}
|
||||||
const binary = atob(cleaned);
|
const binary = atob(cleaned);
|
||||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||||
@@ -77,44 +86,48 @@ export async function decodeTextImage(text: string): Promise<PixelImage> {
|
|||||||
|
|
||||||
export async function encode(
|
export async function encode(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
mime: OutputMime = 'image/png',
|
mime: OutputMime = "image/png",
|
||||||
quality?: number
|
quality?: number,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
if (mime === 'image/bmp') {
|
if (mime === "image/bmp") {
|
||||||
return new Blob([encodeBmpBytes(img)], { type: mime });
|
return new Blob([encodeBmpBytes(img)], { type: mime });
|
||||||
}
|
}
|
||||||
if (mime === 'image/jpeg' || mime === 'image/webp') {
|
if (mime === "image/jpeg" || mime === "image/webp") {
|
||||||
if (quality !== undefined && (quality < 0 || quality > 1)) {
|
if (quality !== undefined && (quality < 0 || quality > 1)) {
|
||||||
throw new ToolError('errors.qualityRange');
|
throw new ToolError("errors.qualityRange");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = img.width;
|
canvas.width = img.width;
|
||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new ToolError('errors.noCanvasCtx');
|
throw new ToolError("errors.noCanvasCtx");
|
||||||
}
|
}
|
||||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||||
return await canvasToBlob(canvas, mime, quality);
|
return await canvasToBlob(canvas, mime, quality);
|
||||||
}
|
}
|
||||||
|
|
||||||
function canvasToBlob(canvas: HTMLCanvasElement, mime: OutputMime, quality?: number): Promise<Blob> {
|
function canvasToBlob(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
mime: OutputMime,
|
||||||
|
quality?: number,
|
||||||
|
): Promise<Blob> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) =>
|
(blob) =>
|
||||||
blob
|
blob
|
||||||
? resolve(blob)
|
? resolve(blob)
|
||||||
: reject(new ToolError('errors.encodeUnsupported', { mime })),
|
: reject(new ToolError("errors.encodeUnsupported", { mime })),
|
||||||
mime,
|
mime,
|
||||||
quality
|
quality,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadBlob(blob: Blob, filename: string): void {
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
document.body.append(a);
|
document.body.append(a);
|
||||||
@@ -124,43 +137,54 @@ export function downloadBlob(blob: Blob, filename: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function replaceExtension(filename: string, ext: string): string {
|
export function replaceExtension(filename: string, ext: string): string {
|
||||||
const base = filename.replace(/\.[^./\\]+$/, '');
|
const base = filename.replace(/\.[^./\\]+$/, "");
|
||||||
return `${base}.${ext}`;
|
return `${base}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decodeSvgText(text: string, targetWidth?: number): Promise<PixelImage> {
|
export async function decodeSvgText(
|
||||||
|
text: string,
|
||||||
|
targetWidth?: number,
|
||||||
|
): Promise<PixelImage> {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
throw new ToolError('errors.svgSize');
|
throw new ToolError("errors.svgSize");
|
||||||
}
|
}
|
||||||
const blob = new Blob([trimmed], { type: 'image/svg+xml' });
|
const blob = new Blob([trimmed], { type: "image/svg+xml" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
try {
|
try {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
img.onload = () => resolve();
|
img.onload = () => resolve();
|
||||||
img.onerror = () => reject(new ToolError('errors.svgLoad'));
|
img.onerror = () => reject(new ToolError("errors.svgLoad"));
|
||||||
img.src = url;
|
img.src = url;
|
||||||
});
|
});
|
||||||
const w = targetWidth ?? img.naturalWidth ?? 300;
|
const w = targetWidth ?? img.naturalWidth ?? 300;
|
||||||
const ratio = img.naturalHeight > 0 ? img.naturalHeight / img.naturalWidth : 1;
|
const ratio =
|
||||||
|
img.naturalHeight > 0 ? img.naturalHeight / img.naturalWidth : 1;
|
||||||
const h = Math.max(1, Math.round(w * ratio));
|
const h = Math.max(1, Math.round(w * ratio));
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = w;
|
canvas.width = w;
|
||||||
canvas.height = h;
|
canvas.height = h;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
if (!ctx) throw new ToolError('errors.noCanvasCtx');
|
if (!ctx) throw new ToolError("errors.noCanvasCtx");
|
||||||
ctx.drawImage(img, 0, 0, w, h);
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
const imageData = ctx.getImageData(0, 0, w, h);
|
const imageData = ctx.getImageData(0, 0, w, h);
|
||||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
return {
|
||||||
|
width: imageData.width,
|
||||||
|
height: imageData.height,
|
||||||
|
data: imageData.data,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function jpegRoundtrip(img: PixelImage, qualityPercent: number): Promise<PixelImage> {
|
export async function jpegRoundtrip(
|
||||||
|
img: PixelImage,
|
||||||
|
qualityPercent: number,
|
||||||
|
): Promise<PixelImage> {
|
||||||
const quality = Math.min(Math.max(Math.trunc(qualityPercent), 1), 100) / 100;
|
const quality = Math.min(Math.max(Math.trunc(qualityPercent), 1), 100) / 100;
|
||||||
const jpegBlob = await encode(img, 'image/jpeg', quality);
|
const jpegBlob = await encode(img, "image/jpeg", quality);
|
||||||
const bitmap = await createImageBitmap(jpegBlob);
|
const bitmap = await createImageBitmap(jpegBlob);
|
||||||
try {
|
try {
|
||||||
return await decodeBitmap(bitmap);
|
return await decodeBitmap(bitmap);
|
||||||
|
|||||||
@@ -1,79 +1,75 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
extractByColor,
|
extractByColor,
|
||||||
isGrayscaleish,
|
isGrayscaleish,
|
||||||
luma01,
|
luma01,
|
||||||
rarityPredicate,
|
rarityPredicate,
|
||||||
renderPredicateMask
|
renderPredicateMask,
|
||||||
} from './masks';
|
} from "./masks";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
const px = makeImage(2, 1, [
|
const px = makeImage(2, 1, [
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[10, 10, 10, 255]
|
[10, 10, 10, 255],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
describe('isGrayscaleish / luma01', () => {
|
describe("isGrayscaleish / luma01", () => {
|
||||||
it('серый распознаётся с допуском, цветной — нет', () => {
|
it("серый распознаётся с допуском, цветной — нет", () => {
|
||||||
expect(isGrayscaleish(10, 10, 10, 0)).toBe(true);
|
expect(isGrayscaleish(10, 10, 10, 0)).toBe(true);
|
||||||
expect(isGrayscaleish(12, 10, 11, 2)).toBe(true);
|
expect(isGrayscaleish(12, 10, 11, 2)).toBe(true);
|
||||||
expect(isGrayscaleish(255, 0, 0, 0)).toBe(false);
|
expect(isGrayscaleish(255, 0, 0, 0)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('luma01: белый 1, чёрный 0', () => {
|
it("luma01: белый 1, чёрный 0", () => {
|
||||||
expect(luma01(255, 255, 255)).toBeCloseTo(1);
|
expect(luma01(255, 255, 255)).toBeCloseTo(1);
|
||||||
expect(luma01(0, 0, 0)).toBeCloseTo(0);
|
expect(luma01(0, 0, 0)).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renderPredicateMask', () => {
|
describe("renderPredicateMask", () => {
|
||||||
it('binary: совпавшие белые на чёрном, альфа принудительная', () => {
|
it("binary: совпавшие белые на чёрном, альфа принудительная", () => {
|
||||||
const out = renderPredicateMask(
|
const out = renderPredicateMask(px, (r) => r > 200, { mode: "binary" });
|
||||||
px,
|
|
||||||
(r) => r > 200,
|
|
||||||
{ mode: 'binary' }
|
|
||||||
);
|
|
||||||
expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 255]);
|
expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 255]);
|
||||||
expect([...out.data.slice(4, 8)]).toEqual([0, 0, 0, 255]);
|
expect([...out.data.slice(4, 8)]).toEqual([0, 0, 0, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('highlight: подкрашивает только совпавшие, остальные без изменений', () => {
|
it("highlight: подкрашивает только совпавшие, остальные без изменений", () => {
|
||||||
const out = renderPredicateMask(
|
const out = renderPredicateMask(px, (r) => r > 200, {
|
||||||
px,
|
mode: "highlight",
|
||||||
(r) => r > 200,
|
color: "#0000ff",
|
||||||
{ mode: 'highlight', color: '#0000ff', opacityPercent: 100 }
|
opacityPercent: 100,
|
||||||
);
|
});
|
||||||
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 255, 255]);
|
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 255, 255]);
|
||||||
expect([...out.data.slice(4, 8)]).toEqual([10, 10, 10, 255]);
|
expect([...out.data.slice(4, 8)]).toEqual([10, 10, 10, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('прозрачность оригинала сохраняется в highlight-режиме', () => {
|
it("прозрачность оригинала сохраняется в highlight-режиме", () => {
|
||||||
const semi = makeImage(1, 1, [[200, 200, 200, 128]]);
|
const semi = makeImage(1, 1, [[200, 200, 200, 128]]);
|
||||||
const out = renderPredicateMask(semi, () => true, {
|
const out = renderPredicateMask(semi, () => true, {
|
||||||
mode: 'highlight',
|
mode: "highlight",
|
||||||
color: '#00ff00',
|
color: "#00ff00",
|
||||||
opacityPercent: 50
|
opacityPercent: 50,
|
||||||
});
|
});
|
||||||
expect(out.data[3]).toBe(128);
|
expect(out.data[3]).toBe(128);
|
||||||
expect(out.data[0]).toBeGreaterThan(90);
|
expect(out.data[0]).toBeGreaterThan(90);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rarityPredicate + extractByColor', () => {
|
describe("rarityPredicate + extractByColor", () => {
|
||||||
const img = makeImage(3, 1, [
|
const img = makeImage(3, 1, [
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[255, 0, 0, 255],
|
[255, 0, 0, 255],
|
||||||
[0, 255, 0, 255]
|
[0, 255, 0, 255],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
it('уникальный (единичный) цвет находится, массовый — нет', () => {
|
it("уникальный (единичный) цвет находится, массовый — нет", () => {
|
||||||
const pred = rarityPredicate(img, 1);
|
const pred = rarityPredicate(img, 1);
|
||||||
expect(pred(0, 255, 0, 255)).toBe(true);
|
expect(pred(0, 255, 0, 255)).toBe(true);
|
||||||
expect(pred(255, 0, 0, 255)).toBe(false);
|
expect(pred(255, 0, 0, 255)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extractByColor оставляет близкие и делает дальние прозрачными', () => {
|
it("extractByColor оставляет близкие и делает дальние прозрачными", () => {
|
||||||
const out = extractByColor(px, '#0a0a0a', 5);
|
const out = extractByColor(px, "#0a0a0a", 5);
|
||||||
expect(out.data[3]).toBe(0);
|
expect(out.data[3]).toBe(0);
|
||||||
expect(out.data[7]).toBe(255);
|
expect(out.data[7]).toBe(255);
|
||||||
});
|
});
|
||||||
|
|||||||
+36
-14
@@ -1,8 +1,8 @@
|
|||||||
import { hexToRgb } from './palette';
|
import { hexToRgb } from "./palette";
|
||||||
import type { PixelImage } from './types';
|
import type { PixelImage } from "./types";
|
||||||
import { createPixelImage } from './types';
|
import { createPixelImage } from "./types";
|
||||||
|
|
||||||
export type MaskMode = 'binary' | 'highlight';
|
export type MaskMode = "binary" | "highlight";
|
||||||
|
|
||||||
export interface MaskOptions {
|
export interface MaskOptions {
|
||||||
/** binary: белое/чёрное без альфы; highlight: подкрасить совпавшие пиксели цветом. */
|
/** binary: белое/чёрное без альфы; highlight: подкрасить совпавшие пиксели цветом. */
|
||||||
@@ -18,10 +18,10 @@ export interface MaskOptions {
|
|||||||
export function renderPredicateMask(
|
export function renderPredicateMask(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
predicate: (r: number, g: number, b: number, a: number) => boolean,
|
predicate: (r: number, g: number, b: number, a: number) => boolean,
|
||||||
o: MaskOptions = {}
|
o: MaskOptions = {},
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
const highlight = o.mode !== 'binary';
|
const highlight = o.mode !== "binary";
|
||||||
const tint = o.color ? hexToRgb(o.color) : { r: 255, g: 0, b: 170 };
|
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;
|
const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100;
|
||||||
if (!highlight) {
|
if (!highlight) {
|
||||||
@@ -34,7 +34,7 @@ export function renderPredicateMask(
|
|||||||
const b = img.data[i + 2];
|
const b = img.data[i + 2];
|
||||||
const a = img.data[i + 3];
|
const a = img.data[i + 3];
|
||||||
if (!predicate(r, g, b, a)) {
|
if (!predicate(r, g, b, a)) {
|
||||||
if (highlight && o.mode === 'highlight') {
|
if (highlight && o.mode === "highlight") {
|
||||||
out.data[i] = r;
|
out.data[i] = r;
|
||||||
out.data[i + 1] = g;
|
out.data[i + 1] = g;
|
||||||
out.data[i + 2] = b;
|
out.data[i + 2] = b;
|
||||||
@@ -57,13 +57,27 @@ export function renderPredicateMask(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maxChannelDelta(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
|
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));
|
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 {
|
export function isGrayscaleish(
|
||||||
|
r: number,
|
||||||
|
g: number,
|
||||||
|
b: number,
|
||||||
|
tolerance: number,
|
||||||
|
): boolean {
|
||||||
return (
|
return (
|
||||||
Math.abs(r - g) <= tolerance && Math.abs(g - b) <= tolerance && Math.abs(r - b) <= tolerance
|
Math.abs(r - g) <= tolerance &&
|
||||||
|
Math.abs(g - b) <= tolerance &&
|
||||||
|
Math.abs(r - b) <= tolerance
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,13 +92,22 @@ export function luma01(r: number, g: number, b: number): number {
|
|||||||
export function extractByColor(
|
export function extractByColor(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
targetHex: string,
|
targetHex: string,
|
||||||
tolerancePercent: number
|
tolerancePercent: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
const t = hexToRgb(targetHex);
|
const t = hexToRgb(targetHex);
|
||||||
const tol = (Math.min(Math.max(tolerancePercent, 0), 100) / 100) * 255;
|
const tol = (Math.min(Math.max(tolerancePercent, 0), 100) / 100) * 255;
|
||||||
for (let i = 0; i < img.data.length; i += 4) {
|
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) {
|
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] = img.data[i];
|
||||||
out.data[i + 1] = img.data[i + 1];
|
out.data[i + 1] = img.data[i + 1];
|
||||||
out.data[i + 2] = img.data[i + 2];
|
out.data[i + 2] = img.data[i + 2];
|
||||||
@@ -99,7 +122,7 @@ export function extractByColor(
|
|||||||
*/
|
*/
|
||||||
export function rarityPredicate(
|
export function rarityPredicate(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
limit: number
|
limit: number,
|
||||||
): (r: number, g: number, b: number, a: number) => boolean {
|
): (r: number, g: number, b: number, a: number) => boolean {
|
||||||
const counts = new Map<number, number>();
|
const counts = new Map<number, number>();
|
||||||
for (let i = 0; i < img.data.length; i += 4) {
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
@@ -111,4 +134,3 @@ export function rarityPredicate(
|
|||||||
return (counts.get(key) ?? 0) <= limit;
|
return (counts.get(key) ?? 0) <= limit;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildAlphaMask,
|
buildAlphaMask,
|
||||||
closingImage,
|
closingImage,
|
||||||
@@ -8,15 +8,15 @@ import {
|
|||||||
erodeImage,
|
erodeImage,
|
||||||
erodeMask,
|
erodeMask,
|
||||||
openingImage,
|
openingImage,
|
||||||
strokeImage
|
strokeImage,
|
||||||
} from './morphology';
|
} from "./morphology";
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
function maskFrom(rows: string[]): Uint8Array {
|
function maskFrom(rows: string[]): Uint8Array {
|
||||||
const flat = rows.join('');
|
const flat = rows.join("");
|
||||||
const mask = new Uint8Array(flat.length);
|
const mask = new Uint8Array(flat.length);
|
||||||
for (let i = 0; i < flat.length; i++) {
|
for (let i = 0; i < flat.length; i++) {
|
||||||
mask[i] = flat[i] === '#' ? 1 : 0;
|
mask[i] = flat[i] === "#" ? 1 : 0;
|
||||||
}
|
}
|
||||||
return mask;
|
return mask;
|
||||||
}
|
}
|
||||||
@@ -24,66 +24,74 @@ function maskFrom(rows: string[]): Uint8Array {
|
|||||||
function toRows(mask: Uint8Array, w: number): string[] {
|
function toRows(mask: Uint8Array, w: number): string[] {
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
for (let y = 0; y < mask.length / w; y++) {
|
for (let y = 0; y < mask.length / w; y++) {
|
||||||
let row = '';
|
let row = "";
|
||||||
for (let x = 0; x < w; x++) {
|
for (let x = 0; x < w; x++) {
|
||||||
row += mask[y * w + x] === 1 ? '#' : '.';
|
row += mask[y * w + x] === 1 ? "#" : ".";
|
||||||
}
|
}
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('dilateMask', () => {
|
describe("dilateMask", () => {
|
||||||
it('одиночный пиксель r=1 превращается в плюс', () => {
|
it("одиночный пиксель r=1 превращается в плюс", () => {
|
||||||
const out = dilateMask(
|
const out = dilateMask(
|
||||||
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
|
maskFrom([".....", ".....", "..#..", ".....", "....."]),
|
||||||
5,
|
5,
|
||||||
5,
|
5,
|
||||||
1
|
1,
|
||||||
);
|
);
|
||||||
expect(toRows(out, 5)).toEqual(['.....', '..#..', '.###.', '..#..', '.....']);
|
expect(toRows(out, 5)).toEqual([
|
||||||
|
".....",
|
||||||
|
"..#..",
|
||||||
|
".###.",
|
||||||
|
"..#..",
|
||||||
|
".....",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('r=2 даёт ромб радиуса 2', () => {
|
it("r=2 даёт ромб радиуса 2", () => {
|
||||||
const out = dilateMask(
|
const out = dilateMask(
|
||||||
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
|
maskFrom([".....", ".....", "..#..", ".....", "....."]),
|
||||||
5,
|
5,
|
||||||
5,
|
5,
|
||||||
2
|
2,
|
||||||
);
|
);
|
||||||
expect(toRows(out, 5)).toEqual(['..#..', '.###.', '#####', '.###.', '..#..']);
|
expect(toRows(out, 5)).toEqual([
|
||||||
|
"..#..",
|
||||||
|
".###.",
|
||||||
|
"#####",
|
||||||
|
".###.",
|
||||||
|
"..#..",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('erodeMask', () => {
|
describe("erodeMask", () => {
|
||||||
it('сплошной объект во весь кадр не сжимается от границ', () => {
|
it("сплошной объект во весь кадр не сжимается от границ", () => {
|
||||||
const solid = maskFrom(['#####', '#####', '#####', '#####', '#####']);
|
const solid = maskFrom(["#####", "#####", "#####", "#####", "#####"]);
|
||||||
expect([...erodeMask(solid, 5, 5, 1)]).toEqual([...solid]);
|
expect([...erodeMask(solid, 5, 5, 1)]).toEqual([...solid]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('изолированный пиксель исчезает', () => {
|
it("изолированный пиксель исчезает", () => {
|
||||||
const out = erodeMask(
|
const out = erodeMask(
|
||||||
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
|
maskFrom([".....", ".....", "..#..", ".....", "....."]),
|
||||||
5,
|
5,
|
||||||
5,
|
5,
|
||||||
1
|
1,
|
||||||
);
|
);
|
||||||
expect([...out].every((v) => v === 0)).toBe(true);
|
expect([...out].every((v) => v === 0)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('opening/closing образы', () => {
|
describe("opening/closing образы", () => {
|
||||||
it('opening убирает отстоящий мусорный пиксель и сохраняет блок', () => {
|
it("opening убирает отстоящий мусорный пиксель и сохраняет блок", () => {
|
||||||
const B = [10, 10, 10, 255];
|
const B = [10, 10, 10, 255];
|
||||||
const T = [0, 0, 0, 0];
|
const T = [0, 0, 0, 0];
|
||||||
const S = [200, 200, 200, 255];
|
const S = [200, 200, 200, 255];
|
||||||
const out = openingImage(
|
const out = openingImage(
|
||||||
makeImage(
|
makeImage(6, 3, [B, B, B, T, S, T, B, B, B, T, T, T, B, B, B, T, T, T]),
|
||||||
6,
|
1,
|
||||||
3,
|
|
||||||
[B, B, B, T, S, T, B, B, B, T, T, T, B, B, B, T, T, T]
|
|
||||||
),
|
|
||||||
1
|
|
||||||
);
|
);
|
||||||
const at = (x: number, y: number) => out.data[(y * 6 + x) * 4 + 3];
|
const at = (x: number, y: number) => out.data[(y * 6 + x) * 4 + 3];
|
||||||
expect(at(0, 1)).toBe(255);
|
expect(at(0, 1)).toBe(255);
|
||||||
@@ -91,7 +99,7 @@ describe('opening/closing образы', () => {
|
|||||||
expect(at(4, 1)).toBe(0);
|
expect(at(4, 1)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closing заполняет одиночную прозрачную дыру чёрным', () => {
|
it("closing заполняет одиночную прозрачную дыру чёрным", () => {
|
||||||
const pixels: number[][] = [];
|
const pixels: number[][] = [];
|
||||||
for (let y = 0; y < 3; y++) {
|
for (let y = 0; y < 3; y++) {
|
||||||
for (let x = 0; x < 3; x++) {
|
for (let x = 0; x < 3; x++) {
|
||||||
@@ -104,27 +112,32 @@ describe('opening/closing образы', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('image-обёртки', () => {
|
describe("image-обёртки", () => {
|
||||||
it('dilateImage сохраняет RGB содержимого, новые пиксели непрозрачны', () => {
|
it("dilateImage сохраняет RGB содержимого, новые пиксели непрозрачны", () => {
|
||||||
const T = [0, 0, 0, 0];
|
const T = [0, 0, 0, 0];
|
||||||
const O = [200, 50, 25, 255];
|
const O = [200, 50, 25, 255];
|
||||||
const img = makeImage(5, 2, [T, O, T, T, T, T, T, T, T, T]);
|
const img = makeImage(5, 2, [T, O, T, T, T, T, T, T, T, T]);
|
||||||
const out = dilateImage(img, 1);
|
const out = dilateImage(img, 1);
|
||||||
const at = (x: number, y: number) => {
|
const at = (x: number, y: number) => {
|
||||||
const di = (y * 5 + x) * 4;
|
const di = (y * 5 + x) * 4;
|
||||||
return [out.data[di], out.data[di + 1], out.data[di + 2], out.data[di + 3]];
|
return [
|
||||||
|
out.data[di],
|
||||||
|
out.data[di + 1],
|
||||||
|
out.data[di + 2],
|
||||||
|
out.data[di + 3],
|
||||||
|
];
|
||||||
};
|
};
|
||||||
expect(at(1, 0)).toEqual([200, 50, 25, 255]);
|
expect(at(1, 0)).toEqual([200, 50, 25, 255]);
|
||||||
expect(at(0, 0)).toEqual([0, 0, 0, 255]);
|
expect(at(0, 0)).toEqual([0, 0, 0, 255]);
|
||||||
expect(at(4, 0)[3]).toBe(0);
|
expect(at(4, 0)[3]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erodeImage не стирает объект у края кадра', () => {
|
it("erodeImage не стирает объект у края кадра", () => {
|
||||||
const img = makeImage(2, 2, [
|
const img = makeImage(2, 2, [
|
||||||
[10, 20, 30, 255],
|
[10, 20, 30, 255],
|
||||||
[10, 20, 30, 255],
|
[10, 20, 30, 255],
|
||||||
[10, 20, 30, 255],
|
[10, 20, 30, 255],
|
||||||
[10, 20, 30, 255]
|
[10, 20, 30, 255],
|
||||||
]);
|
]);
|
||||||
const out = erodeImage(img, 1);
|
const out = erodeImage(img, 1);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
@@ -133,34 +146,44 @@ describe('image-обёртки', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('strokeImage', () => {
|
describe("strokeImage", () => {
|
||||||
it('кольцо цвета обводки вокруг квадрата', () => {
|
it("кольцо цвета обводки вокруг квадрата", () => {
|
||||||
const pixels: number[][] = [];
|
const pixels: number[][] = [];
|
||||||
for (let y = 0; y < 7; y++) {
|
for (let y = 0; y < 7; y++) {
|
||||||
for (let x = 0; x < 7; x++) {
|
for (let x = 0; x < 7; x++) {
|
||||||
pixels.push(x >= 2 && x <= 4 && y >= 2 && y <= 4 ? [255, 0, 0, 255] : [0, 0, 0, 0]);
|
pixels.push(
|
||||||
|
x >= 2 && x <= 4 && y >= 2 && y <= 4
|
||||||
|
? [255, 0, 0, 255]
|
||||||
|
: [0, 0, 0, 0],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const out = strokeImage(makeImage(7, 7, pixels), 1, '#0000ff');
|
const out = strokeImage(makeImage(7, 7, pixels), 1, "#0000ff");
|
||||||
const at = (x: number, y: number) =>
|
const at = (x: number, y: number) => [
|
||||||
[...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4)];
|
...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4),
|
||||||
|
];
|
||||||
expect(at(2, 3)).toEqual([255, 0, 0, 255]);
|
expect(at(2, 3)).toEqual([255, 0, 0, 255]);
|
||||||
expect(at(1, 3)).toEqual([0, 0, 255, 255]);
|
expect(at(1, 3)).toEqual([0, 0, 255, 255]);
|
||||||
expect(at(0, 0)).toEqual([0, 0, 0, 0]);
|
expect(at(0, 0)).toEqual([0, 0, 0, 0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('contourImage', () => {
|
describe("contourImage", () => {
|
||||||
it('линия по краю квадрата, центр прозрачен', () => {
|
it("линия по краю квадрата, центр прозрачен", () => {
|
||||||
const pixels: number[][] = [];
|
const pixels: number[][] = [];
|
||||||
for (let y = 0; y < 7; y++) {
|
for (let y = 0; y < 7; y++) {
|
||||||
for (let x = 0; x < 7; x++) {
|
for (let x = 0; x < 7; x++) {
|
||||||
pixels.push(x >= 2 && x <= 4 && y >= 2 && y <= 4 ? [255, 0, 0, 255] : [0, 0, 0, 0]);
|
pixels.push(
|
||||||
|
x >= 2 && x <= 4 && y >= 2 && y <= 4
|
||||||
|
? [255, 0, 0, 255]
|
||||||
|
: [0, 0, 0, 0],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const out = contourImage(makeImage(7, 7, pixels), 1, '#0000ff');
|
const out = contourImage(makeImage(7, 7, pixels), 1, "#0000ff");
|
||||||
const at = (x: number, y: number) =>
|
const at = (x: number, y: number) => [
|
||||||
[...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4)];
|
...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4),
|
||||||
|
];
|
||||||
expect(at(2, 2)).toEqual([0, 0, 255, 255]);
|
expect(at(2, 2)).toEqual([0, 0, 255, 255]);
|
||||||
expect(at(3, 3)).toEqual([0, 0, 0, 0]);
|
expect(at(3, 3)).toEqual([0, 0, 0, 0]);
|
||||||
expect(at(1, 3)).toEqual([0, 0, 0, 0]);
|
expect(at(1, 3)).toEqual([0, 0, 0, 0]);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user