style: fix formatting via prettier

This commit is contained in:
2026-08-28 14:29:54 +05:00
parent 10278981ba
commit ada96b3534
124 changed files with 8211 additions and 5662 deletions
+1
View File
@@ -6,3 +6,4 @@ pnpm-lock.yaml
static static
*.log *.log
.DS_Store .DS_Store
audit
+6 -3
View File
@@ -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.
+58 -23
View File
@@ -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,7 +262,7 @@ 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)
@@ -250,7 +279,8 @@ function toMarkdown(report, ours, ref) {
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
View File
@@ -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%;
} }
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
]; ];
+13 -9
View File
@@ -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>
+5 -5
View File
@@ -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);
+9 -5
View File
@@ -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 = "";
} }
}} }}
/> />
+10 -8
View File
@@ -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>
+26 -18
View File
@@ -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)}
+39 -16
View File
@@ -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);
} }
+128 -89
View File
@@ -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">
{t("toolPage.stepHeading", { n: index + 2 })}
</h3>
<button <button
type="button" type="button"
class="remove-step" class="remove-step"
aria-label={t('toolPage.removeStepAria')} aria-label={t("toolPage.removeStepAria")}
onclick={() => removeChainStep(index)} onclick={() => removeChainStep(index)}
> >
</button> </button>
</header> </header>
<ToolSearch onSelect={(id) => applyChainTool(index, id)} chainableOnly /> <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>
+6 -1
View File
@@ -22,7 +22,12 @@
</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" variant="bare" 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"
@@ -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()}
+25 -19
View File
@@ -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)}
+76 -41
View File
@@ -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}
+4 -4
View File
@@ -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;
+12 -8
View File
@@ -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>
+27 -11
View File
@@ -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>
+23 -20
View File
@@ -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>
+18 -9
View File
@@ -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>
+15 -8
View File
@@ -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>
+8 -8
View File
@@ -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;
+14 -8
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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;
+3 -3
View File
@@ -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}
+21 -9
View File
@@ -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);
+23 -5
View File
@@ -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>
+53 -35
View File
@@ -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
View File
@@ -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]);
} }
+79 -80
View File
@@ -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(
makeImage(2, 1, [
[1, 2, 3, 255], [1, 2, 3, 255],
[4, 5, 6, 0] [4, 5, 6, 0],
]), 50); ]),
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(
makeImage(2, 1, [
[10, 20, 30, 255], [10, 20, 30, 255],
[40, 50, 60, 0] [40, 50, 60, 0],
])); ]),
expect([...out.data]).toEqual([ );
255, 255, 255, 255, expect([...out.data]).toEqual([255, 255, 255, 255, 0, 0, 0, 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
View File
@@ -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),
]; ];
} }
+38 -27
View File
@@ -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");
}); });
}); });
+15 -7
View File
@@ -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";
} }
+70 -59
View File
@@ -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,
smoothPasses: 0,
});
expect(mask[4]).toBe(0); expect(mask[4]).toBe(0);
}); });
it('глобальный режим удаляет и изолированный остров', () => { it("глобальный режим удаляет и изолированный остров", () => {
const mask = backgroundRemovalMask( const mask = backgroundRemovalMask(makeImage(3, 3, ringRedCenterGreen), {
makeImage(3, 3, ringRedCenterGreen), color: "#00ff00",
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 } tolerancePercent: 0,
); outerOnly: false,
smoothPasses: 0,
});
expect(mask[4]).toBe(1); expect(mask[4]).toBe(1);
expect(mask[0]).toBe(0); expect(mask[0]).toBe(0);
}); });
}); });
it('допуск расширяет захват по цветовому расстоянию', () => { 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
]);
}); });
}); });
+15 -8
View File
@@ -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++) {
+14 -17
View File
@@ -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 -1
View File
@@ -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;
+21 -19
View File
@@ -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);
}); });
}); });
+35 -19
View File
@@ -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 }, },
hsi: { components: ['h', 's', 'i'], convert: rgbToHsi }, hsv: { components: ["h", "s", "v"], convert: rgbToHsv },
cmyk: { components: ['c', 'm', 'y', 'k'], convert: rgbToCmyk }, hsi: { components: ["h", "s", "i"], convert: rgbToHsi },
ycbcr: { components: ['y', 'cb', 'cr'], convert: rgbToYcbcr }, cmyk: { components: ["c", "m", "y", "k"], convert: rgbToCmyk },
lab: { components: ['l', 'a', 'b'], convert: rgbToLab } ycbcr: { components: ["y", "cb", "cr"], convert: rgbToYcbcr },
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;
+87 -77
View File
@@ -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(
makeImage(2, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
[255, 255, 255, 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(
makeImage(2, 1, [
[10, 10, 10, 255], [10, 10, 10, 255],
[200, 200, 200, 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
View File
@@ -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;
+11 -10
View File
@@ -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++;
+2 -2
View File
@@ -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;
+34 -30
View File
@@ -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],
]); ]);
} }
} }
+20 -11
View File
@@ -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++) {
+10 -10
View File
@@ -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М");
}); });
}); });
+7 -7
View File
@@ -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
View File
@@ -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);
} }
+6 -6
View File
@@ -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 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
} }
+30 -30
View File
@@ -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
+16 -16
View File
@@ -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);
}); });
}); });
+18 -11
View File
@@ -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);
+18 -16
View File
@@ -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);
+33 -14
View File
@@ -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;
+60 -63
View File
@@ -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/);
+76 -42
View File
@@ -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;
+34 -26
View File
@@ -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",
])("принимает %s", (type) => {
expect(isSupportedImage(new File([], "x", { type }))).toBe(true);
});
it("отклоняет неподдерживаемый тип", () => {
expect(
isSupportedImage(new File([], "a.txt", { type: "text/plain" })),
).toBe(false);
});
it("отклоняет файл без типа", () => {
expect(isSupportedImage(new File([], "x"))).toBe(false);
});
});
describe("unsupportedImageError", () => {
it("ключ ошибки и тип файла в vars", () => {
const err = unsupportedImageError(
new File([], "a.txt", { type: "text/plain" }),
); );
expect(err.key).toBe("errors.unsupportedFile");
it('отклоняет неподдерживаемый тип', () => { expect(err.vars?.type).toBe("text/plain");
expect(isSupportedImage(new File([], 'a.txt', { type: 'text/plain' }))).toBe(false);
}); });
it('отклоняет файл без типа', () => { it("пустой тип передаётся как unknown", () => {
expect(isSupportedImage(new File([], 'x'))).toBe(false); const err = unsupportedImageError(new File([], "x"));
}); expect(err.vars?.type).toBe("unknown");
});
describe('unsupportedImageError', () => {
it('ключ ошибки и тип файла в vars', () => {
const err = unsupportedImageError(new File([], 'a.txt', { type: 'text/plain' }));
expect(err.key).toBe('errors.unsupportedFile');
expect(err.vars?.type).toBe('text/plain');
});
it('пустой тип передаётся как unknown', () => {
const err = unsupportedImageError(new File([], 'x'));
expect(err.vars?.type).toBe('unknown');
}); });
}); });
+67 -43
View File
@@ -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);
+26 -30
View File
@@ -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
View File
@@ -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;
}; };
} }
+72 -49
View File
@@ -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]);
+40 -10
View File
@@ -1,5 +1,5 @@
import { parseHex } from './alpha'; import { parseHex } from "./alpha";
import { clonePixelImage, createPixelImage, type PixelImage } from './types'; import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
type Mask = Uint8Array; type Mask = Uint8Array;
@@ -25,7 +25,12 @@ export function buildAlphaMask(img: PixelImage): Mask {
return mask; return mask;
} }
export function dilateMask(mask: Mask, width: number, height: number, radius: number): Mask { export function dilateMask(
mask: Mask,
width: number,
height: number,
radius: number,
): Mask {
const r = Math.trunc(radius); const r = Math.trunc(radius);
if (r < 1) return mask.slice(); if (r < 1) return mask.slice();
const out = new Uint8Array(mask.length); const out = new Uint8Array(mask.length);
@@ -48,7 +53,12 @@ export function dilateMask(mask: Mask, width: number, height: number, radius: nu
return out; return out;
} }
export function erodeMask(mask: Mask, width: number, height: number, radius: number): Mask { export function erodeMask(
mask: Mask,
width: number,
height: number,
radius: number,
): Mask {
const r = Math.trunc(radius); const r = Math.trunc(radius);
if (r < 1) return mask.slice(); if (r < 1) return mask.slice();
const out = new Uint8Array(mask.length); const out = new Uint8Array(mask.length);
@@ -89,18 +99,24 @@ export function applyMaskAlpha(img: PixelImage, mask: Mask): PixelImage {
export function dilateImage(img: PixelImage, radiusPx: number): PixelImage { export function dilateImage(img: PixelImage, radiusPx: number): PixelImage {
if (radiusPx < 1) return clonePixelImage(img); if (radiusPx < 1) return clonePixelImage(img);
return applyMaskAlpha(img, dilateMask(buildAlphaMask(img), img.width, img.height, radiusPx)); return applyMaskAlpha(
img,
dilateMask(buildAlphaMask(img), img.width, img.height, radiusPx),
);
} }
export function erodeImage(img: PixelImage, radiusPx: number): PixelImage { export function erodeImage(img: PixelImage, radiusPx: number): PixelImage {
if (radiusPx < 1) return clonePixelImage(img); if (radiusPx < 1) return clonePixelImage(img);
return applyMaskAlpha(img, erodeMask(buildAlphaMask(img), img.width, img.height, radiusPx)); return applyMaskAlpha(
img,
erodeMask(buildAlphaMask(img), img.width, img.height, radiusPx),
);
} }
export function strokeImage( export function strokeImage(
img: PixelImage, img: PixelImage,
radiusPx: number, radiusPx: number,
colorHex: string colorHex: string,
): PixelImage { ): PixelImage {
const r = Math.trunc(radiusPx); const r = Math.trunc(radiusPx);
if (r < 1) return clonePixelImage(img); if (r < 1) return clonePixelImage(img);
@@ -125,7 +141,11 @@ export function strokeImage(
return out; return out;
} }
export function contourImage(img: PixelImage, radiusPx: number, colorHex: string): PixelImage { export function contourImage(
img: PixelImage,
radiusPx: number,
colorHex: string,
): PixelImage {
const r = Math.max(1, Math.trunc(radiusPx)); const r = Math.max(1, Math.trunc(radiusPx));
const [cr, cg, cb] = parseHex(colorHex); const [cr, cg, cb] = parseHex(colorHex);
const mask = buildAlphaMask(img); const mask = buildAlphaMask(img);
@@ -147,11 +167,21 @@ export function contourImage(img: PixelImage, radiusPx: number, colorHex: string
return out; return out;
} }
export function openingMask(mask: Mask, w: number, h: number, radius: number): Mask { export function openingMask(
mask: Mask,
w: number,
h: number,
radius: number,
): Mask {
return dilateMask(erodeMask(mask, w, h, radius), w, h, radius); return dilateMask(erodeMask(mask, w, h, radius), w, h, radius);
} }
export function closingMask(mask: Mask, w: number, h: number, radius: number): Mask { export function closingMask(
mask: Mask,
w: number,
h: number,
radius: number,
): Mask {
return erodeMask(dilateMask(mask, w, h, radius), w, h, radius); return erodeMask(dilateMask(mask, w, h, radius), w, h, radius);
} }
+56 -51
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { import {
analogousSet, analogousSet,
complementarySet, complementarySet,
@@ -16,60 +16,60 @@ import {
shadeSet, shadeSet,
sortPalette, sortPalette,
triadicSet, triadicSet,
tetradicSet tetradicSet,
} from './palette'; } from "./palette";
describe('конвертация rgb↔hsl', () => { describe("конвертация rgb↔hsl", () => {
it('красный → hsl(0,100%,50%) и обратно', () => { it("красный → hsl(0,100%,50%) и обратно", () => {
const hsl = rgbToHsl(hexToRgb('#ff0000')); const hsl = rgbToHsl(hexToRgb("#ff0000"));
expect(hsl.h).toBeCloseTo(0, 0); expect(hsl.h).toBeCloseTo(0, 0);
expect(hsl.s).toBeCloseTo(1, 5); expect(hsl.s).toBeCloseTo(1, 5);
expect(hsl.l).toBeCloseTo(0.5, 5); expect(hsl.l).toBeCloseTo(0.5, 5);
expect(rgbToHex(hslToRgb({ h: 0, s: 1, l: 0.5 }))).toBe('#ff0000'); expect(rgbToHex(hslToRgb({ h: 0, s: 1, l: 0.5 }))).toBe("#ff0000");
}); });
it('серые не имеют оттенка', () => { it("серые не имеют оттенка", () => {
expect(rgbToHsl(hexToRgb('#808080')).s).toBe(0); expect(rgbToHsl(hexToRgb("#808080")).s).toBe(0);
}); });
it('круговой переход 360° возвращает исходный цвет', () => { it("круговой переход 360° возвращает исходный цвет", () => {
const base = '#3b82f6'; const base = "#3b82f6";
expect(rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 }))).toBe( expect(
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 30 })) rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 })),
); ).toBe(rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 30 })));
}); });
}); });
describe('гармонии', () => { describe("гармонии", () => {
it('complementary — пара с сдвигом 180°', () => { it("complementary — пара с сдвигом 180°", () => {
const [a, b] = complementarySet('#ff0000'); const [a, b] = complementarySet("#ff0000");
expect(a).toBe('#ff0000'); expect(a).toBe("#ff0000");
const ha = rgbToHsl(hexToRgb(a)).h; const ha = rgbToHsl(hexToRgb(a)).h;
const hb = rgbToHsl(hexToRgb(b)).h; const hb = rgbToHsl(hexToRgb(b)).h;
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0); expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
}); });
it('triadic — три цвета через 120°', () => { it("triadic — три цвета через 120°", () => {
const set = triadicSet('#ff0000'); const set = triadicSet("#ff0000");
expect(set).toHaveLength(3); expect(set).toHaveLength(3);
const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h); const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h);
expect(hues[1] - hues[0]).toBeCloseTo(120, 0); expect(hues[1] - hues[0]).toBeCloseTo(120, 0);
expect(hues[2] - hues[0]).toBeCloseTo(240, 0); expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
}); });
it('tetradic — четыре цвета через 90°', () => { it("tetradic — четыре цвета через 90°", () => {
const set = tetradicSet('#00ff00'); const set = tetradicSet("#00ff00");
expect(set).toHaveLength(4); expect(set).toHaveLength(4);
}); });
it('analogous симметричен вокруг базы', () => { it("analogous симметричен вокруг базы", () => {
const set = analogousSet('#0000ff', 30, 5); const set = analogousSet("#0000ff", 30, 5);
expect(set).toHaveLength(5); expect(set).toHaveLength(5);
expect(set[2]).toBe(normalizeHex('#0000ff')); expect(set[2]).toBe(normalizeHex("#0000ff"));
}); });
it('monochromatic держит оттенок, меняет светлоту', () => { it("monochromatic держит оттенок, меняет светлоту", () => {
const set = monochromaticSet('#ff8800', 5, 60); const set = monochromaticSet("#ff8800", 5, 60);
expect(set).toHaveLength(5); expect(set).toHaveLength(5);
const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h))); const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h)));
expect(hues.size).toBe(1); expect(hues.size).toBe(1);
@@ -77,62 +77,67 @@ describe('гармонии', () => {
expect(Math.min(...lights)).toBeLessThan(Math.max(...lights)); expect(Math.min(...lights)).toBeLessThan(Math.max(...lights));
}); });
it('shades — тёмный край темнее базы', () => { it("shades — тёмный край темнее базы", () => {
const set = shadeSet('#88cc44', 4, 70); const set = shadeSet("#88cc44", 4, 70);
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l); const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
expect(lights[lights.length - 1]).toBeLessThan(lights[0]); expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
}); });
}); });
describe('parseHexList / mixColors / sortPalette', () => { describe("parseHexList / mixColors / sortPalette", () => {
it('парсит список и отбрасывает мусор и токены без решётки', () => { it("парсит список и отбрасывает мусор и токены без решётки", () => {
expect(parseHexList('#ff0000, #00FF00 ; 00ff00 zz')).toEqual(['#ff0000', '#00ff00']); expect(parseHexList("#ff0000, #00FF00 ; 00ff00 zz")).toEqual([
"#ff0000",
"#00ff00",
]);
}); });
it('пустой валидный список бросает badHex', () => { it("пустой валидный список бросает badHex", () => {
expect(() => parseHexList('нет цветов')).toThrow(/errors\.badHex/); expect(() => parseHexList("нет цветов")).toThrow(/errors\.badHex/);
}); });
it('mixColors — среднее компонент', () => { it("mixColors — среднее компонент", () => {
expect(mixColors(['#000000', '#ffffff'])).toBe('#808080'); expect(mixColors(["#000000", "#ffffff"])).toBe("#808080");
}); });
it('sortPalette по luma ставит тёмные раньше', () => { it("sortPalette по luma ставит тёмные раньше", () => {
const sorted = sortPalette(['#ffffff', '#000000', '#808080'], 'luma'); const sorted = sortPalette(["#ffffff", "#000000", "#808080"], "luma");
expect(sorted[0]).toBe('#000000'); expect(sorted[0]).toBe("#000000");
expect(sorted[2]).toBe('#ffffff'); expect(sorted[2]).toBe("#ffffff");
}); });
}); });
describe('рендеры', () => { describe("рендеры", () => {
it('renderSwatches strip: колонки по цветам', () => { it("renderSwatches strip: колонки по цветам", () => {
const img = renderSwatches(['#ff0000', '#00ff00'], 200, 'strip'); const img = renderSwatches(["#ff0000", "#00ff00"], 200, "strip");
expect(img.width).toBe(200); expect(img.width).toBe(200);
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4 + 3]).toBe(255); expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4 + 3]).toBe(255);
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4]).toBeGreaterThan(200); expect(
img.data[(Math.floor(img.height / 2) * 200 + 10) * 4],
).toBeGreaterThan(200);
const right = (Math.floor(img.height / 2) * 200 + 150) * 4; const right = (Math.floor(img.height / 2) * 200 + 150) * 4;
expect(img.data[right]).toBeLessThan(50); expect(img.data[right]).toBeLessThan(50);
expect(img.data[right + 1]).toBeGreaterThan(200); expect(img.data[right + 1]).toBeGreaterThan(200);
}); });
it('renderSwatches grid: квадратные ячейки', () => { it("renderSwatches grid: квадратные ячейки", () => {
const img = renderSwatches(['#111111', '#222222', '#333333'], 120, 'grid'); const img = renderSwatches(["#111111", "#222222", "#333333"], 120, "grid");
expect(img.width).toBe(120); expect(img.width).toBe(120);
expect(img.height).toBeGreaterThan(0); expect(img.height).toBeGreaterThan(0);
}); });
it('renderWheel: углы прозрачны, центр непрозрачен', () => { it("renderWheel: углы прозрачны, центр непрозрачен", () => {
const size = 101; const size = 101;
const img = renderWheel(size, 50); const img = renderWheel(size, 50);
expect(img.data[3]).toBe(0); expect(img.data[3]).toBe(0);
const c = ((Math.floor(size / 2) * size) + Math.floor(size / 2)) * 4; const c = (Math.floor(size / 2) * size + Math.floor(size / 2)) * 4;
expect(img.data[c + 3]).toBe(255); expect(img.data[c + 3]).toBe(255);
}); });
it('renderBlend: левый край = a, правый = b', () => { it("renderBlend: левый край = a, правый = b", () => {
const img = renderBlend('#000000', '#ffffff', 100); const img = renderBlend("#000000", "#ffffff", 100);
expect(img.data[0]).toBe(0); expect(img.data[0]).toBe(0);
const last = (99 * 4); const last = 99 * 4;
expect(img.data[last]).toBe(255); expect(img.data[last]).toBe(255);
}); });
}); });
+79 -28
View File
@@ -1,25 +1,25 @@
import { ToolError } from './errors'; import { ToolError } from "./errors";
import type { PixelImage } from './types'; import type { PixelImage } from "./types";
import { createPixelImage } from './types'; import { createPixelImage } from "./types";
export type Rgb = { r: number; g: number; b: number }; export type Rgb = { r: number; g: number; b: number };
export type Hsl = { h: number; s: number; l: number }; export type Hsl = { h: number; s: number; l: number };
export function hexToRgb(hex: string): Rgb { export function hexToRgb(hex: string): Rgb {
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim()); const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) throw new ToolError('errors.badHex', { value: hex }); if (!m) throw new ToolError("errors.badHex", { value: hex });
const d = m[1]; const d = m[1];
return { return {
r: parseInt(d.slice(0, 2), 16), r: parseInt(d.slice(0, 2), 16),
g: parseInt(d.slice(2, 4), 16), g: parseInt(d.slice(2, 4), 16),
b: parseInt(d.slice(4, 6), 16) b: parseInt(d.slice(4, 6), 16),
}; };
} }
const byte = (v: number) => const byte = (v: number) =>
Math.round(Math.min(255, Math.max(0, v))) Math.round(Math.min(255, Math.max(0, v)))
.toString(16) .toString(16)
.padStart(2, '0'); .padStart(2, "0");
export function rgbToHex({ r, g, b }: Rgb): string { export function rgbToHex({ r, g, b }: Rgb): string {
return `#${byte(r)}${byte(g)}${byte(b)}`; return `#${byte(r)}${byte(g)}${byte(b)}`;
@@ -60,7 +60,7 @@ export function hslToRgb({ h, s, l }: Hsl): Rgb {
return { return {
r: Math.round((r + m) * 255), r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255), g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255) b: Math.round((b + m) * 255),
}; };
} }
@@ -82,16 +82,31 @@ export function triadicSet(base: string): string[] {
} }
export function tetradicSet(base: string): string[] { export function tetradicSet(base: string): string[] {
return [normalizeHex(base), shiftHue(base, 90), shiftHue(base, 180), shiftHue(base, 270)]; return [
normalizeHex(base),
shiftHue(base, 90),
shiftHue(base, 180),
shiftHue(base, 270),
];
} }
export function analogousSet(base: string, spreadDeg: number, count: number): string[] { export function analogousSet(
base: string,
spreadDeg: number,
count: number,
): string[] {
const n = Math.max(3, Math.min(9, Math.round(count))); const n = Math.max(3, Math.min(9, Math.round(count)));
const half = Math.floor(n / 2); const half = Math.floor(n / 2);
return Array.from({ length: n }, (_, i) => shiftHue(base, (i - half) * spreadDeg)); return Array.from({ length: n }, (_, i) =>
shiftHue(base, (i - half) * spreadDeg),
);
} }
export function monochromaticSet(base: string, count: number, rangePercent: number): string[] { export function monochromaticSet(
base: string,
count: number,
rangePercent: number,
): string[] {
const n = Math.max(2, Math.min(9, Math.round(count))); const n = Math.max(2, Math.min(9, Math.round(count)));
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l; const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
const halfSpan = Math.min(0.495, rangePercent / 200); const halfSpan = Math.min(0.495, rangePercent / 200);
@@ -102,7 +117,11 @@ export function monochromaticSet(base: string, count: number, rangePercent: numb
}); });
} }
export function shadeSet(base: string, count: number, depthPercent: number): string[] { export function shadeSet(
base: string,
count: number,
depthPercent: number,
): string[] {
const n = Math.max(2, Math.min(9, Math.round(count))); const n = Math.max(2, Math.min(9, Math.round(count)));
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l; const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
const floorL = Math.max(0.03, baseL - depthPercent / 100); const floorL = Math.max(0.03, baseL - depthPercent / 100);
@@ -118,24 +137,28 @@ export function parseHexList(text: string): string[] {
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => /^#[0-9a-f]{6}$/i.test(t)) .filter((t) => /^#[0-9a-f]{6}$/i.test(t))
.map((t) => normalizeHex(t)); .map((t) => normalizeHex(t));
if (list.length === 0) throw new ToolError('errors.badHex', { value: text }); if (list.length === 0) throw new ToolError("errors.badHex", { value: text });
return list; return list;
} }
export function normalizeHex(hex: string): string { export function normalizeHex(hex: string): string {
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim()); const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) throw new ToolError('errors.badHex', { value: hex }); if (!m) throw new ToolError("errors.badHex", { value: hex });
return `#${m[1].toLowerCase()}`; return `#${m[1].toLowerCase()}`;
} }
export function mixColors(hexes: string[]): string { export function mixColors(hexes: string[]): string {
const sum = hexes const sum = hexes
.map(hexToRgb) .map(hexToRgb)
.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), { r: 0, g: 0, b: 0 }); .reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), {
r: 0,
g: 0,
b: 0,
});
return rgbToHex({ return rgbToHex({
r: sum.r / hexes.length, r: sum.r / hexes.length,
g: sum.g / hexes.length, g: sum.g / hexes.length,
b: sum.b / hexes.length b: sum.b / hexes.length,
}); });
} }
@@ -144,15 +167,17 @@ export function luma(hex: string): number {
return 0.2126 * r + 0.7152 * g + 0.0722 * b; return 0.2126 * r + 0.7152 * g + 0.0722 * b;
} }
export type SortKey = 'hue' | 'luma' | 'sat'; export type SortKey = "hue" | "luma" | "sat";
export function sortPalette(hexes: string[], key: SortKey): string[] { export function sortPalette(hexes: string[], key: SortKey): string[] {
const scored = hexes.map((h) => { const scored = hexes.map((h) => {
if (key === 'luma') return { h, k: luma(h) }; if (key === "luma") return { h, k: luma(h) };
const hsl = rgbToHsl(hexToRgb(h)); const hsl = rgbToHsl(hexToRgb(h));
return { h, k: key === 'hue' ? hsl.h : hsl.s }; return { h, k: key === "hue" ? hsl.h : hsl.s };
}); });
return scored.sort((a, b) => a.k - b.k || a.h.localeCompare(b.h)).map((s) => s.h); return scored
.sort((a, b) => a.k - b.k || a.h.localeCompare(b.h))
.map((s) => s.h);
} }
function clamp01(v: number): number { function clamp01(v: number): number {
@@ -160,15 +185,28 @@ function clamp01(v: number): number {
} }
/** Горизонтальные равные колонки-свотчи (strip) или сетка ~квадратных ячеек (grid). */ /** Горизонтальные равные колонки-свотчи (strip) или сетка ~квадратных ячеек (grid). */
export function renderSwatches(colors: string[], width: number, layout: 'strip' | 'grid'): PixelImage { export function renderSwatches(
colors: string[],
width: number,
layout: "strip" | "grid",
): PixelImage {
const n = colors.length; const n = colors.length;
if (layout === 'strip') { if (layout === "strip") {
const cellW = width / n; const cellW = width / n;
const height = Math.max(24, Math.round(cellW)); const height = Math.max(24, Math.round(cellW));
const out = createPixelImage(width, height); const out = createPixelImage(width, height);
colors.forEach((hex, i) => { colors.forEach((hex, i) => {
const { r, g, b } = hexToRgb(hex); const { r, g, b } = hexToRgb(hex);
fillRect(out, Math.floor(i * cellW), 0, Math.ceil(cellW), height, r, g, b); fillRect(
out,
Math.floor(i * cellW),
0,
Math.ceil(cellW),
height,
r,
g,
b,
);
}); });
return out; return out;
} }
@@ -179,7 +217,16 @@ export function renderSwatches(colors: string[], width: number, layout: 'strip'
const out = createPixelImage(width, height); const out = createPixelImage(width, height);
colors.forEach((hex, i) => { colors.forEach((hex, i) => {
const { r, g, b } = hexToRgb(hex); const { r, g, b } = hexToRgb(hex);
fillRect(out, (i % cols) * cell, Math.floor(i / cols) * cell, cell, cell, r, g, b); fillRect(
out,
(i % cols) * cell,
Math.floor(i / cols) * cell,
cell,
cell,
r,
g,
b,
);
}); });
return out; return out;
} }
@@ -222,13 +269,17 @@ export function renderBlend(a: string, b: string, width: number): PixelImage {
height, height,
Math.round(ca.r + (cb.r - ca.r) * t), Math.round(ca.r + (cb.r - ca.r) * t),
Math.round(ca.g + (cb.g - ca.g) * t), Math.round(ca.g + (cb.g - ca.g) * t),
Math.round(ca.b + (cb.b - ca.b) * t) Math.round(ca.b + (cb.b - ca.b) * t),
); );
} }
return out; return out;
} }
export function stepColors(aHex: string, bHex: string, steps: number): string[] { export function stepColors(
aHex: string,
bHex: string,
steps: number,
): string[] {
const n = Math.max(2, Math.min(12, Math.round(steps))); const n = Math.max(2, Math.min(12, Math.round(steps)));
const ca = hexToRgb(aHex); const ca = hexToRgb(aHex);
const cb = hexToRgb(bHex); const cb = hexToRgb(bHex);
@@ -237,7 +288,7 @@ export function stepColors(aHex: string, bHex: string, steps: number): string[]
return rgbToHex({ return rgbToHex({
r: ca.r + (cb.r - ca.r) * t, r: ca.r + (cb.r - ca.r) * t,
g: ca.g + (cb.g - ca.g) * t, g: ca.g + (cb.g - ca.g) * t,
b: ca.b + (cb.b - ca.b) * t b: ca.b + (cb.b - ca.b) * t,
}); });
}); });
} }
@@ -250,7 +301,7 @@ function fillRect(
h: number, h: number,
r: number, r: number,
g: number, g: number,
b: number b: number,
): void { ): void {
for (let y = y0; y < Math.min(y0 + h, img.height); y++) { for (let y = y0; y < Math.min(y0 + h, img.height); y++) {
for (let x = x0; x < Math.min(x0 + w, img.width); x++) { for (let x = x0; x < Math.min(x0 + w, img.width); x++) {
+41 -33
View File
@@ -1,63 +1,71 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { addNoise, defringe, featherAlpha, mulberry32, pixelate, shuffleBlocks, silhouette } from './pixel-fx'; import {
import { makeImage } from './test-helpers'; addNoise,
defringe,
featherAlpha,
mulberry32,
pixelate,
shuffleBlocks,
silhouette,
} from "./pixel-fx";
import { makeImage } from "./test-helpers";
describe('pixelate', () => { describe("pixelate", () => {
it('блок усредняется: шахматка 2×2 с блоком 2 → один цвет', () => { it("блок усредняется: шахматка 2×2 с блоком 2 → один цвет", () => {
const img = makeImage(2, 2, [ const img = makeImage(2, 2, [
[0, 0, 0, 255], [0, 0, 0, 255],
[200, 200, 200, 255], [200, 200, 200, 255],
[100, 100, 100, 255], [100, 100, 100, 255],
[140, 140, 140, 255] [140, 140, 140, 255],
]); ]);
const out = pixelate(img, 2); const out = pixelate(img, 2);
expect(out.data[0]).toBeCloseTo(110, 0); expect(out.data[0]).toBeCloseTo(110, 0);
expect(out.data[4]).toBeCloseTo(110, 0); expect(out.data[4]).toBeCloseTo(110, 0);
}); });
it('однотонное изображение не меняется', () => { it("однотонное изображение не меняется", () => {
const img = makeImage(3, 3, new Array(9).fill([50, 60, 70, 255])); const img = makeImage(3, 3, new Array(9).fill([50, 60, 70, 255]));
expect([...pixelate(img, 2).data]).toEqual([...img.data]); expect([...pixelate(img, 2).data]).toEqual([...img.data]);
}); });
}); });
describe('shuffleBlocks / addNoise — детерминизм по seed', () => { describe("shuffleBlocks / addNoise — детерминизм по seed", () => {
it('тот же seed даёт то же перемешивание', () => { it("тот же seed даёт то же перемешивание", () => {
const img = makeImage(4, 1, [ const img = makeImage(4, 1, [
[10, 0, 0, 255], [10, 0, 0, 255],
[20, 0, 0, 255], [20, 0, 0, 255],
[30, 0, 0, 255], [30, 0, 0, 255],
[40, 0, 0, 255] [40, 0, 0, 255],
]); ]);
const a = shuffleBlocks(img, 1, 7); const a = shuffleBlocks(img, 1, 7);
const b = shuffleBlocks(img, 1, 7); const b = shuffleBlocks(img, 1, 7);
expect([...a.data]).toEqual([...b.data]); expect([...a.data]).toEqual([...b.data]);
}); });
it('мультимножество пикселей сохраняется (перестановка)', () => { it("мультимножество пикселей сохраняется (перестановка)", () => {
const img = makeImage(4, 1, [ const img = makeImage(4, 1, [
[10, 0, 0, 255], [10, 0, 0, 255],
[20, 0, 0, 255], [20, 0, 0, 255],
[30, 0, 0, 255], [30, 0, 0, 255],
[40, 0, 0, 255] [40, 0, 0, 255],
]); ]);
const out = [...shuffleBlocks(img, 1, 99).data.filter((_, i) => i % 4 === 0)].sort( const out = [
(x, y) => x - y ...shuffleBlocks(img, 1, 99).data.filter((_, i) => i % 4 === 0),
); ].sort((x, y) => x - y);
expect(out).toEqual([10, 20, 30, 40]); expect(out).toEqual([10, 20, 30, 40]);
}); });
it('addNoise при том же seed воспроизводим, amount=0 — идентичность', () => { it("addNoise при том же seed воспроизводим, amount=0 — идентичность", () => {
const img = makeImage(2, 2, new Array(4).fill([128, 64, 32, 255])); const img = makeImage(2, 2, new Array(4).fill([128, 64, 32, 255]));
const a = addNoise(img, 20, 'mono', 5); const a = addNoise(img, 20, "mono", 5);
const b = addNoise(img, 20, 'mono', 5); const b = addNoise(img, 20, "mono", 5);
expect([...a.data]).toEqual([...b.data]); expect([...a.data]).toEqual([...b.data]);
expect([...addNoise(img, 0, 'mono', 999).data]).toEqual([...img.data]); expect([...addNoise(img, 0, "mono", 999).data]).toEqual([...img.data]);
}); });
}); });
describe('featherAlpha', () => { describe("featherAlpha", () => {
it('жёсткий край получает промежуточные альфы', () => { it("жёсткий край получает промежуточные альфы", () => {
// левая половина непрозрачная, правая прозрачная // левая половина непрозрачная, правая прозрачная
const img = makeImage(6, 1, [ const img = makeImage(6, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
@@ -65,7 +73,7 @@ describe('featherAlpha', () => {
[255, 0, 0, 255], [255, 0, 0, 255],
[255, 0, 0, 0], [255, 0, 0, 0],
[255, 0, 0, 0], [255, 0, 0, 0],
[255, 0, 0, 0] [255, 0, 0, 0],
]); ]);
const out = featherAlpha(img, 1); const out = featherAlpha(img, 1);
const alphas = [0, 1, 2].map((x) => out.data[x * 4 + 3]); const alphas = [0, 1, 2].map((x) => out.data[x * 4 + 3]);
@@ -74,11 +82,11 @@ describe('featherAlpha', () => {
}); });
}); });
describe('defringe', () => { describe("defringe", () => {
it('полупрозрачному пикселю берётся RGB от соседнего непрозрачного', () => { it("полупрозрачному пикселю берётся RGB от соседнего непрозрачного", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[250, 250, 250, 255], [250, 250, 250, 255],
[255, 0, 0, 128] [255, 0, 0, 128],
]); ]);
const out = defringe(img, 2); const out = defringe(img, 2);
expect(out.data[4]).toBe(250); expect(out.data[4]).toBe(250);
@@ -86,30 +94,30 @@ describe('defringe', () => {
expect(out.data[7]).toBe(128); // альфа сохранена expect(out.data[7]).toBe(128); // альфа сохранена
}); });
it('полностью прозрачные области не трогаются', () => { it("полностью прозрачные области не трогаются", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[250, 250, 250, 255], [250, 250, 250, 255],
[0, 0, 0, 0] [0, 0, 0, 0],
]); ]);
const out = defringe(img, 2); const out = defringe(img, 2);
expect(out.data[4 + 3]).toBe(0); expect(out.data[4 + 3]).toBe(0);
}); });
}); });
describe('silhouette', () => { describe("silhouette", () => {
it('видимые пиксели заливаются цветом, ниже порога — прозрачность', () => { it("видимые пиксели заливаются цветом, ниже порога — прозрачность", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[123, 45, 67, 255], [123, 45, 67, 255],
[123, 45, 67, 10] [123, 45, 67, 10],
]); ]);
const out = silhouette(img, '#00ff00', 50); const out = silhouette(img, "#00ff00", 50);
expect([...out.data.slice(0, 4)]).toEqual([0, 255, 0, 255]); expect([...out.data.slice(0, 4)]).toEqual([0, 255, 0, 255]);
expect(out.data[4 + 3]).toBe(0); expect(out.data[4 + 3]).toBe(0);
}); });
}); });
describe('mulberry32', () => { describe("mulberry32", () => {
it('последовательность детерминирована', () => { it("последовательность детерминирована", () => {
const a = mulberry32(42); const a = mulberry32(42);
const b = mulberry32(42); const b = mulberry32(42);
expect([a(), a(), a()]).toEqual([b(), b(), b()]); expect([a(), a(), a()]).toEqual([b(), b(), b()]);
+25 -12
View File
@@ -1,7 +1,7 @@
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";
import { gaussianBlur } from './convolution'; import { gaussianBlur } from "./convolution";
/** Детерминированный ГПСЧ (mulberry32): одинаковый seed — одинаковый результат. */ /** Детерминированный ГПСЧ (mulberry32): одинаковый seed — одинаковый результат. */
export function mulberry32(seed: number): () => number { export function mulberry32(seed: number): () => number {
@@ -59,7 +59,11 @@ export function pixelate(img: PixelImage, blockSize: number): PixelImage {
} }
/** Перемешивает блоки blockSize×Blocksize между собой детерминированно по seed. */ /** Перемешивает блоки blockSize×Blocksize между собой детерминированно по seed. */
export function shuffleBlocks(img: PixelImage, blockSize: number, seed: number): PixelImage { export function shuffleBlocks(
img: PixelImage,
blockSize: number,
seed: number,
): PixelImage {
const bs = Math.max(1, Math.round(blockSize)); const bs = Math.max(1, Math.round(blockSize));
const cols = Math.ceil(img.width / bs); const cols = Math.ceil(img.width / bs);
const rows = Math.ceil(img.height / bs); const rows = Math.ceil(img.height / bs);
@@ -92,28 +96,32 @@ export function shuffleBlocks(img: PixelImage, blockSize: number, seed: number):
return out; return out;
} }
export type NoiseMode = 'mono' | 'color'; export type NoiseMode = "mono" | "color";
/** Зерно: amountPercent — сила отклонения от оригинала. Детерминировано по seed. */ /** Зерно: amountPercent — сила отклонения от оригинала. Детерминировано по seed. */
export function addNoise( export function addNoise(
img: PixelImage, img: PixelImage,
amountPercent: number, amountPercent: number,
mode: NoiseMode, mode: NoiseMode,
seed: number seed: number,
): PixelImage { ): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
const amount = Math.min(Math.max(amountPercent, 0), 100) / 100; const amount = Math.min(Math.max(amountPercent, 0), 100) / 100;
const rng = mulberry32(seed); const rng = mulberry32(seed);
for (let i = 0; i < img.data.length; i += 4) { for (let i = 0; i < img.data.length; i += 4) {
const shift = (rng() * 2 - 1) * amount * 255; const shift = (rng() * 2 - 1) * amount * 255;
if (mode === 'mono') { if (mode === "mono") {
out.data[i] = clampByte(img.data[i] + shift); out.data[i] = clampByte(img.data[i] + shift);
out.data[i + 1] = clampByte(img.data[i + 1] + shift); out.data[i + 1] = clampByte(img.data[i + 1] + shift);
out.data[i + 2] = clampByte(img.data[i + 2] + shift); out.data[i + 2] = clampByte(img.data[i + 2] + shift);
} else { } else {
out.data[i] = clampByte(img.data[i] + (rng() * 2 - 1) * amount * 255); out.data[i] = clampByte(img.data[i] + (rng() * 2 - 1) * amount * 255);
out.data[i + 1] = clampByte(img.data[i + 1] + (rng() * 2 - 1) * amount * 255); out.data[i + 1] = clampByte(
out.data[i + 2] = clampByte(img.data[i + 2] + (rng() * 2 - 1) * amount * 255); img.data[i + 1] + (rng() * 2 - 1) * amount * 255,
);
out.data[i + 2] = clampByte(
img.data[i + 2] + (rng() * 2 - 1) * amount * 255,
);
} }
out.data[i + 3] = img.data[i + 3]; out.data[i + 3] = img.data[i + 3];
} }
@@ -160,7 +168,8 @@ export function defringe(img: PixelImage, radius: number): PixelImage {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ry) continue; if (Math.max(Math.abs(dx), Math.abs(dy)) !== ry) continue;
const nx = x + dx; const nx = x + dx;
const ny = y + dy; const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= img.width || ny >= img.height) continue; if (nx < 0 || ny < 0 || nx >= img.width || ny >= img.height)
continue;
const si = (ny * img.width + nx) * 4; const si = (ny * img.width + nx) * 4;
if (img.data[si + 3] !== 255) continue; if (img.data[si + 3] !== 255) continue;
out.data[di] = img.data[si]; out.data[di] = img.data[si];
@@ -176,7 +185,11 @@ export function defringe(img: PixelImage, radius: number): PixelImage {
} }
/** Силуэт: все видимые пиксели заливаются одним цветом, альфа сохраняется. */ /** Силуэт: все видимые пиксели заливаются одним цветом, альфа сохраняется. */
export function silhouette(img: PixelImage, colorHex: string, alphaThreshold: number): PixelImage { export function silhouette(
img: PixelImage,
colorHex: string,
alphaThreshold: number,
): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
const { r, g, b } = hexToRgb(colorHex); const { r, g, b } = hexToRgb(colorHex);
for (let i = 0; i < img.data.length; i += 4) { for (let i = 0; i < img.data.length; i += 4) {
+28 -23
View File
@@ -1,37 +1,42 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { ditherImage, mapToNearest, medianCutPalette, quantizeImage } from './quantize'; import {
import { makeImage } from './test-helpers'; ditherImage,
mapToNearest,
medianCutPalette,
quantizeImage,
} from "./quantize";
import { makeImage } from "./test-helpers";
describe('medianCutPalette', () => { describe("medianCutPalette", () => {
it('два явных кластера при k=2 дают сами цвета', () => { it("два явных кластера при k=2 дают сами цвета", () => {
const img = makeImage(4, 1, [ const img = makeImage(4, 1, [
[0, 0, 0, 255], [0, 0, 0, 255],
[0, 0, 0, 255], [0, 0, 0, 255],
[255, 255, 255, 255], [255, 255, 255, 255],
[255, 255, 255, 255] [255, 255, 255, 255],
]); ]);
const palette = medianCutPalette(img, 2); const palette = medianCutPalette(img, 2);
expect(palette).toHaveLength(2); expect(palette).toHaveLength(2);
const hexes = palette.map((c) => `${c.r},${c.g},${c.b}`).sort(); const hexes = palette.map((c) => `${c.r},${c.g},${c.b}`).sort();
expect(hexes).toEqual(['0,0,0', '255,255,255']); expect(hexes).toEqual(["0,0,0", "255,255,255"]);
}); });
it('пустое изображение даёт чёрную заглушку', () => { it("пустое изображение даёт чёрную заглушку", () => {
const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0])); const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0]));
expect(medianCutPalette(empty, 4)).toHaveLength(1); expect(medianCutPalette(empty, 4)).toHaveLength(1);
}); });
}); });
describe('quantizeImage / ditherImage', () => { describe("quantizeImage / ditherImage", () => {
it('квантование укладывает пиксели в палитру, прозрачность сохраняется', () => { it("квантование укладывает пиксели в палитру, прозрачность сохраняется", () => {
const img = makeImage(3, 1, [ const img = makeImage(3, 1, [
[10, 10, 10, 255], [10, 10, 10, 255],
[250, 250, 250, 255], [250, 250, 250, 255],
[0, 0, 0, 0] [0, 0, 0, 0],
]); ]);
const { image, palette } = quantizeImage(img, 2); const { image, palette } = quantizeImage(img, 2);
expect(palette.length).toBeLessThanOrEqual(2); expect(palette.length).toBeLessThanOrEqual(2);
expect(image.data[(2) * 4 + 3]).toBe(0); // прозрачный остался expect(image.data[2 * 4 + 3]).toBe(0); // прозрачный остался
for (let x = 0; x < 2; x++) { for (let x = 0; x < 2; x++) {
const i = x * 4; const i = x * 4;
const matched = palette.some((hex) => { const matched = palette.some((hex) => {
@@ -42,9 +47,9 @@ describe('quantizeImage / ditherImage', () => {
} }
}); });
it('floyd-steinberg расщепляет серый 50% на чёрное и белое', () => { it("floyd-steinberg расщепляет серый 50% на чёрное и белое", () => {
const gray = makeImage(16, 16, new Array(256).fill([128, 128, 128, 255])); const gray = makeImage(16, 16, new Array(256).fill([128, 128, 128, 255]));
const out = ditherImage(gray, 2, 'floyd-steinberg', ['#000000', '#ffffff']); const out = ditherImage(gray, 2, "floyd-steinberg", ["#000000", "#ffffff"]);
let hasDark = false; let hasDark = false;
let hasLight = false; let hasLight = false;
for (let i = 0; i < out.data.length; i += 4) { for (let i = 0; i < out.data.length; i += 4) {
@@ -55,27 +60,27 @@ describe('quantizeImage / ditherImage', () => {
expect(hasLight).toBe(true); expect(hasLight).toBe(true);
}); });
it('bayer детерминирован', () => { it("bayer детерминирован", () => {
const gray = makeImage(8, 8, new Array(64).fill([128, 128, 128, 255])); const gray = makeImage(8, 8, new Array(64).fill([128, 128, 128, 255]));
const a = ditherImage(gray, 2, 'bayer'); const a = ditherImage(gray, 2, "bayer");
const b = ditherImage(gray, 2, 'bayer'); const b = ditherImage(gray, 2, "bayer");
expect([...a.data]).toEqual([...b.data]); expect([...a.data]).toEqual([...b.data]);
}); });
it('полностью прозрачное изображение не падает', () => { it("полностью прозрачное изображение не падает", () => {
const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0])); const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0]));
const out = ditherImage(empty, 4, 'bayer'); const out = ditherImage(empty, 4, "bayer");
expect(out.data[3]).toBe(0); expect(out.data[3]).toBe(0);
}); });
}); });
describe('mapToNearest', () => { describe("mapToNearest", () => {
it('маппинг на ближайший из списка', () => { it("маппинг на ближайший из списка", () => {
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[10, 10, 10, 255], [10, 10, 10, 255],
[240, 240, 240, 255] [240, 240, 240, 255],
]); ]);
const out = mapToNearest(img, ['#000000', '#ffffff']); const out = mapToNearest(img, ["#000000", "#ffffff"]);
expect(out.data[0]).toBe(0); expect(out.data[0]).toBe(0);
expect(out.data[4]).toBe(255); expect(out.data[4]).toBe(255);
}); });
+30 -17
View File
@@ -1,7 +1,7 @@
import type { PixelImage } from './types'; import type { PixelImage } from "./types";
import { createPixelImage } from './types'; import { createPixelImage } from "./types";
import { rgbToHex } from './palette'; import { rgbToHex } from "./palette";
import { hexToRgb } from './palette'; import { hexToRgb } from "./palette";
type Rgb = { r: number; g: number; b: number }; type Rgb = { r: number; g: number; b: number };
@@ -73,9 +73,9 @@ export function medianCutPalette(img: PixelImage, k: number): Rgb[] {
const bucket = buckets[targetIdx]; const bucket = buckets[targetIdx];
const ranges = [ const ranges = [
{ ch: 'r' as const, range: bucket.max.r - bucket.min.r }, { ch: "r" as const, range: bucket.max.r - bucket.min.r },
{ ch: 'g' as const, range: bucket.max.g - bucket.min.g }, { ch: "g" as const, range: bucket.max.g - bucket.min.g },
{ ch: 'b' as const, range: bucket.max.b - bucket.min.b } { ch: "b" as const, range: bucket.max.b - bucket.min.b },
].sort((a, b) => b.range - a.range); ].sort((a, b) => b.range - a.range);
const widest = ranges[0].ch; const widest = ranges[0].ch;
bucket.px.sort((a, b) => a[widest] - b[widest]); bucket.px.sort((a, b) => a[widest] - b[widest]);
@@ -84,7 +84,7 @@ export function medianCutPalette(img: PixelImage, k: number): Rgb[] {
...buckets.slice(0, targetIdx), ...buckets.slice(0, targetIdx),
makeBucket(bucket.px.slice(0, mid)), makeBucket(bucket.px.slice(0, mid)),
makeBucket(bucket.px.slice(mid)), makeBucket(bucket.px.slice(mid)),
...buckets.slice(targetIdx + 1) ...buckets.slice(targetIdx + 1),
]; ];
} }
@@ -93,7 +93,7 @@ export function medianCutPalette(img: PixelImage, k: number): Rgb[] {
.map((b) => ({ .map((b) => ({
r: Math.round(b.px.reduce((s, c) => s + c.r, 0) / b.px.length), r: Math.round(b.px.reduce((s, c) => s + c.r, 0) / b.px.length),
g: Math.round(b.px.reduce((s, c) => s + c.g, 0) / b.px.length), g: Math.round(b.px.reduce((s, c) => s + c.g, 0) / b.px.length),
b: Math.round(b.px.reduce((s, c) => s + c.b, 0) / b.px.length) b: Math.round(b.px.reduce((s, c) => s + c.b, 0) / b.px.length),
})); }));
} }
@@ -109,7 +109,12 @@ export function quantizeImage(img: PixelImage, k: number): QuantizeResult {
for (let i = 0; i < img.data.length; i += 4) { for (let i = 0; i < img.data.length; i += 4) {
out.data[i + 3] = img.data[i + 3]; out.data[i + 3] = img.data[i + 3];
if (img.data[i + 3] === 0) continue; if (img.data[i + 3] === 0) continue;
const chosen = nearestIndex(palette, img.data[i], img.data[i + 1], img.data[i + 2]); const chosen = nearestIndex(
palette,
img.data[i],
img.data[i + 1],
img.data[i + 2],
);
out.data[i] = palette[chosen].r; out.data[i] = palette[chosen].r;
out.data[i + 1] = palette[chosen].g; out.data[i + 1] = palette[chosen].g;
out.data[i + 2] = palette[chosen].b; out.data[i + 2] = palette[chosen].b;
@@ -118,13 +123,21 @@ export function quantizeImage(img: PixelImage, k: number): QuantizeResult {
} }
/** Маппинг каждого пикселя на ближайший цвет пользовательского списка. */ /** Маппинг каждого пикселя на ближайший цвет пользовательского списка. */
export function mapToNearest(img: PixelImage, paletteHexes: string[]): PixelImage { export function mapToNearest(
img: PixelImage,
paletteHexes: string[],
): PixelImage {
const palette = paletteHexes.map(hexToRgb); const palette = paletteHexes.map(hexToRgb);
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) {
out.data[i + 3] = img.data[i + 3]; out.data[i + 3] = img.data[i + 3];
if (img.data[i + 3] === 0) continue; if (img.data[i + 3] === 0) continue;
const chosen = nearestIndex(palette, img.data[i], img.data[i + 1], img.data[i + 2]); const chosen = nearestIndex(
palette,
img.data[i],
img.data[i + 1],
img.data[i + 2],
);
out.data[i] = palette[chosen].r; out.data[i] = palette[chosen].r;
out.data[i + 1] = palette[chosen].g; out.data[i + 1] = palette[chosen].g;
out.data[i + 2] = palette[chosen].b; out.data[i + 2] = palette[chosen].b;
@@ -136,10 +149,10 @@ const BAYER_4 = [
[0, 8, 2, 10], [0, 8, 2, 10],
[12, 4, 14, 6], [12, 4, 14, 6],
[3, 11, 1, 9], [3, 11, 1, 9],
[15, 7, 13, 5] [15, 7, 13, 5],
]; ];
export type DitherPattern = 'floyd-steinberg' | 'bayer'; export type DitherPattern = "floyd-steinberg" | "bayer";
/** /**
* Дизеринг к палитре из k цветов (median-cut) или к явно заданному списку hex. * Дизеринг к палитре из k цветов (median-cut) или к явно заданному списку hex.
@@ -149,7 +162,7 @@ export function ditherImage(
img: PixelImage, img: PixelImage,
k: number, k: number,
pattern: DitherPattern, pattern: DitherPattern,
forcedPaletteHexes?: string[] forcedPaletteHexes?: string[],
): PixelImage { ): PixelImage {
const palette = forcedPaletteHexes const palette = forcedPaletteHexes
? forcedPaletteHexes.map(hexToRgb) ? forcedPaletteHexes.map(hexToRgb)
@@ -177,7 +190,7 @@ export function ditherImage(
let g = buf[p * 3 + 1]; let g = buf[p * 3 + 1];
let b = buf[p * 3 + 2]; let b = buf[p * 3 + 2];
if (pattern === 'bayer') { if (pattern === "bayer") {
const offset = ((BAYER_4[y % 4][x % 4] + 0.5) / 16 - 0.5) * spread; const offset = ((BAYER_4[y % 4][x % 4] + 0.5) / 16 - 0.5) * spread;
r += offset; r += offset;
g += offset; g += offset;
@@ -189,7 +202,7 @@ export function ditherImage(
out.data[di + 1] = palette[chosen].g; out.data[di + 1] = palette[chosen].g;
out.data[di + 2] = palette[chosen].b; out.data[di + 2] = palette[chosen].b;
if (pattern !== 'floyd-steinberg') continue; if (pattern !== "floyd-steinberg") continue;
const er = r - palette[chosen].r; const er = r - palette[chosen].r;
const eg = g - palette[chosen].g; const eg = g - palette[chosen].g;
const eb = b - palette[chosen].b; const eb = b - palette[chosen].b;
+21 -14
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { boxTest, circleTest, renderShape, starTest, wavyTest } from './shapes'; import { boxTest, circleTest, renderShape, starTest, wavyTest } from "./shapes";
import { makeImage } from './test-helpers'; import { makeImage } from "./test-helpers";
describe('тесты фигур', () => { describe("тесты фигур", () => {
it('круг: центр внутри, угол снаружи', () => { it("круг: центр внутри, угол снаружи", () => {
const t = circleTest(0.5); const t = circleTest(0.5);
expect(t(0, 0)).toBe(true); expect(t(0, 0)).toBe(true);
expect(t(0.49, 0)).toBe(true); expect(t(0.49, 0)).toBe(true);
@@ -11,21 +11,23 @@ describe('тесты фигур', () => {
expect(t(0.4, 0.4)).toBe(false); expect(t(0.4, 0.4)).toBe(false);
}); });
it('прямоугольник: полуоси независимы', () => { it("прямоугольник: полуоси независимы", () => {
const t = boxTest(0.5, 0.25); const t = boxTest(0.5, 0.25);
expect(t(0.45, 0.2)).toBe(true); expect(t(0.45, 0.2)).toBe(true);
expect(t(0.2, 0.3)).toBe(false); expect(t(0.2, 0.3)).toBe(false);
}); });
it('звезда: луч внутри дальше впадины', () => { it("звезда: луч внутри дальше впадины", () => {
const t = starTest(5, 0.5, 1, 0); const t = starTest(5, 0.5, 1, 0);
expect(t(0.9, 0)).toBe(true); // вдоль луча (θ=0) expect(t(0.9, 0)).toBe(true); // вдоль луча (θ=0)
const valleyAngle = Math.PI / 5; // середина между лучами const valleyAngle = Math.PI / 5; // середина между лучами
expect(t(Math.cos(valleyAngle) * 0.8, Math.sin(valleyAngle) * 0.8)).toBe(false); expect(t(Math.cos(valleyAngle) * 0.8, Math.sin(valleyAngle) * 0.8)).toBe(
false,
);
expect(t(0.4, 0)).toBe(true); // радиус впадин 0.5 — 0.4 внутри всегда expect(t(0.4, 0)).toBe(true); // радиус впадин 0.5 — 0.4 внутри всегда
}); });
it('волна: фаза двигает границу', () => { it("волна: фаза двигает границу", () => {
const a = wavyTest(0.5, 0.1, 6, 0); const a = wavyTest(0.5, 0.1, 6, 0);
const b = wavyTest(0.5, 0.1, 6, 180); const b = wavyTest(0.5, 0.1, 6, 180);
const deg = 15; // sin(6·15°)=1 → край 0.6; при фазе 180° край 0.4 const deg = 15; // sin(6·15°)=1 → край 0.6; при фазе 180° край 0.4
@@ -36,19 +38,24 @@ describe('тесты фигур', () => {
}); });
}); });
describe('renderShape', () => { describe("renderShape", () => {
const img = makeImage(4, 2, new Array(8).fill([255, 255, 255, 255])); const img = makeImage(4, 2, new Array(8).fill([255, 255, 255, 255]));
it('внутри сохраняет пиксели, снаружи альфа 0', () => { it("внутри сохраняет пиксели, снаружи альфа 0", () => {
const out = renderShape(img, boxTest(0.5, 0.5)); const out = renderShape(img, boxTest(0.5, 0.5));
let opaque = 0; let opaque = 0;
for (let i = 3; i < out.data.length; i += 4) if (out.data[i] === 255) opaque++; for (let i = 3; i < out.data.length; i += 4)
if (out.data[i] === 255) opaque++;
expect(opaque).toBe(4); expect(opaque).toBe(4);
expect(out.data[4]).toBe(255); // RGB внутри фигуры сохранён expect(out.data[4]).toBe(255); // RGB внутри фигуры сохранён
}); });
it('смещение центра переносит маску', () => { it("смещение центра переносит маску", () => {
const shifted = renderShape(makeImage(2, 2, new Array(4).fill([255, 255, 255, 255])), circleTest(0.7), 0.5); const shifted = renderShape(
makeImage(2, 2, new Array(4).fill([255, 255, 255, 255])),
circleTest(0.7),
0.5,
);
expect(shifted.data[3]).toBe(0); expect(shifted.data[3]).toBe(0);
expect(shifted.data[4 + 3]).toBe(255); expect(shifted.data[4 + 3]).toBe(255);
}); });
+5 -5
View File
@@ -1,5 +1,5 @@
import type { PixelImage } from './types'; import type { PixelImage } from "./types";
import { createPixelImage } from './types'; import { createPixelImage } from "./types";
export type ShapeTest = (nx: number, ny: number) => boolean; export type ShapeTest = (nx: number, ny: number) => boolean;
@@ -21,7 +21,7 @@ export function starTest(
points: number, points: number,
innerFrac: number, innerFrac: number,
outerFrac: number, outerFrac: number,
rotationDeg: number rotationDeg: number,
): ShapeTest { ): ShapeTest {
const n = Math.max(3, Math.round(points)); const n = Math.max(3, Math.round(points));
const rot = (rotationDeg * Math.PI) / 180; const rot = (rotationDeg * Math.PI) / 180;
@@ -41,7 +41,7 @@ export function wavyTest(
baseFrac: number, baseFrac: number,
amplitudeFrac: number, amplitudeFrac: number,
waves: number, waves: number,
phaseDeg: number phaseDeg: number,
): ShapeTest { ): ShapeTest {
const phase = (phaseDeg * Math.PI) / 180; const phase = (phaseDeg * Math.PI) / 180;
return (nx, ny) => { return (nx, ny) => {
@@ -60,7 +60,7 @@ export function renderShape(
img: PixelImage, img: PixelImage,
test: ShapeTest, test: ShapeTest,
offsetXFrac = 0, offsetXFrac = 0,
offsetYFrac = 0 offsetYFrac = 0,
): PixelImage { ): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
const minDim = Math.min(img.width, img.height); const minDim = Math.min(img.width, img.height);
+14 -5
View File
@@ -1,14 +1,23 @@
import { expect } from 'vitest'; import { expect } from "vitest";
import type { PixelImage } from './types'; import type { PixelImage } from "./types";
export function makeImage(width: number, height: number, pixels: number[][]): PixelImage { export function makeImage(
width: number,
height: number,
pixels: number[][],
): PixelImage {
const data = new Uint8ClampedArray(pixels.flat()); const data = new Uint8ClampedArray(pixels.flat());
if (data.length !== width * height * 4) { if (data.length !== width * height * 4) {
throw new Error(`Fixture mismatch: ${data.length} байт на ${width}x${height}`); throw new Error(
`Fixture mismatch: ${data.length} байт на ${width}x${height}`,
);
} }
return { width, height, data }; return { width, height, data };
} }
export function expectImageEqual(actual: PixelImage, expectedPixels: number[][]): void { export function expectImageEqual(
actual: PixelImage,
expectedPixels: number[][],
): void {
expect([...actual.data]).toEqual(expectedPixels.flat()); expect([...actual.data]).toEqual(expectedPixels.flat());
} }
+18 -20
View File
@@ -1,47 +1,45 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { hexToPixels, pixelsToHex } from './text'; import { hexToPixels, pixelsToHex } from "./text";
import { makeImage } from './test-helpers'; import { makeImage } from "./test-helpers";
describe('pixelsToHex', () => { describe("pixelsToHex", () => {
it('форматирует пиксели как rrggbbaa построчно', () => { it("форматирует пиксели как rrggbbaa построчно", () => {
const out = pixelsToHex( const out = pixelsToHex(
makeImage(2, 2, [ makeImage(2, 2, [
[255, 0, 0, 255], [255, 0, 0, 255],
[0, 255, 0, 200], [0, 255, 0, 200],
[16, 32, 48, 64], [16, 32, 48, 64],
[0, 0, 0, 0] [0, 0, 0, 0],
]) ]),
); );
expect(out).toBe('ff0000ff 00ff00c8\n10203040 00000000'); expect(out).toBe("ff0000ff 00ff00c8\n10203040 00000000");
}); });
}); });
describe('hexToPixels', () => { describe("hexToPixels", () => {
it('обратим к pixelsToHex', () => { it("обратим к pixelsToHex", () => {
const source = makeImage(3, 1, [ const source = makeImage(3, 1, [
[1, 2, 3, 4], [1, 2, 3, 4],
[250, 251, 252, 253], [250, 251, 252, 253],
[9, 9, 9, 128] [9, 9, 9, 128],
]); ]);
expect(hexToPixels(pixelsToHex(source), 3)).toEqual(source); expect(hexToPixels(pixelsToHex(source), 3)).toEqual(source);
}); });
it('допускает произвольные переводы строк и регистр', () => { it("допускает произвольные переводы строк и регистр", () => {
const out = hexToPixels('FF0000FF\n\n00FF0080 00000080', 1); const out = hexToPixels("FF0000FF\n\n00FF0080 00000080", 1);
expect(out.width).toBe(1); expect(out.width).toBe(1);
expect(out.height).toBe(3); expect(out.height).toBe(3);
expect([...out.data]).toEqual([ expect([...out.data]).toEqual([
255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 0, 128,
0, 255, 0, 128,
0, 0, 0, 128
]); ]);
}); });
it.each([ it.each([
['ff0000', 'битые токены'], ["ff0000", "битые токены"],
['ff0000ff ff0000ff ff0000ff', 'не делится на ширину'], ["ff0000ff ff0000ff ff0000ff", "не делится на ширину"],
['', 'пустой ввод'] ["", "пустой ввод"],
])('бросает понятную ошибку: %s (%s)', (input) => { ])("бросает понятную ошибку: %s (%s)", (input) => {
expect(() => hexToPixels(input, 2)).toThrow(); expect(() => hexToPixels(input, 2)).toThrow();
}); });
}); });
+20 -12
View File
@@ -1,5 +1,5 @@
import type { PixelImage } from './types'; import type { PixelImage } from "./types";
import { ToolError } from './errors'; import { ToolError } from "./errors";
export function pixelsToHex(img: PixelImage): string { export function pixelsToHex(img: PixelImage): string {
const rows: string[] = []; const rows: string[] = [];
@@ -7,29 +7,37 @@ export function pixelsToHex(img: PixelImage): string {
const parts: string[] = []; const parts: string[] = [];
for (let x = 0; x < img.width; x++) { for (let x = 0; x < img.width; x++) {
const i = (y * img.width + x) * 4; const i = (y * img.width + x) * 4;
parts.push(byteHex(img.data[i]) + byteHex(img.data[i + 1]) + byteHex(img.data[i + 2]) + byteHex(img.data[i + 3])); parts.push(
byteHex(img.data[i]) +
byteHex(img.data[i + 1]) +
byteHex(img.data[i + 2]) +
byteHex(img.data[i + 3]),
);
} }
rows.push(parts.join(' ')); rows.push(parts.join(" "));
} }
return rows.join('\n'); return rows.join("\n");
} }
export function hexToPixels(text: string, width: number): PixelImage { export function hexToPixels(text: string, width: number): PixelImage {
if (!Number.isInteger(width) || width < 1) { if (!Number.isInteger(width) || width < 1) {
throw new ToolError('errors.widthInt'); throw new ToolError("errors.widthInt");
} }
const tokens = text.trim().split(/\s+/).filter((t) => t.length > 0); const tokens = text
.trim()
.split(/\s+/)
.filter((t) => t.length > 0);
if (tokens.length === 0) { if (tokens.length === 0) {
throw new ToolError('errors.noHexPixels'); throw new ToolError("errors.noHexPixels");
} }
if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) { if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) {
throw new ToolError('errors.badPixelToken'); throw new ToolError("errors.badPixelToken");
} }
const height = tokens.length / width; const height = tokens.length / width;
if (!Number.isInteger(height)) { if (!Number.isInteger(height)) {
throw new ToolError('errors.pixelCountMismatch', { throw new ToolError("errors.pixelCountMismatch", {
count: tokens.length, count: tokens.length,
width width,
}); });
} }
const data = new Uint8ClampedArray(tokens.length * 4); const data = new Uint8ClampedArray(tokens.length * 4);
@@ -43,5 +51,5 @@ export function hexToPixels(text: string, width: number): PixelImage {
} }
function byteHex(value: number): string { function byteHex(value: number): string {
return value.toString(16).padStart(2, '0'); return value.toString(16).padStart(2, "0");
} }
+49 -31
View File
@@ -1,49 +1,67 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { anchorOrigin, tileGrid, wrapText } from './textdraw'; import { anchorOrigin, tileGrid, wrapText } from "./textdraw";
const measure = (s: string) => s.length * 10; const measure = (s: string) => s.length * 10;
describe('anchorOrigin', () => { describe("anchorOrigin", () => {
it('углы и отступ считаются от краёв', () => { it("углы и отступ считаются от краёв", () => {
expect(anchorOrigin('top-left', 100, 50, 400, 300, 30)).toEqual({ x: 30, y: 30 }); expect(anchorOrigin("top-left", 100, 50, 400, 300, 30)).toEqual({
expect(anchorOrigin('top-right', 100, 50, 400, 300, 30)).toEqual({ x: 270, y: 30 }); x: 30,
expect(anchorOrigin('bottom-left', 100, 50, 400, 300, 30)).toEqual({ x: 30, y: 220 }); y: 30,
expect(anchorOrigin('bottom-right', 100, 50, 400, 300, 30)).toEqual({ x: 270, y: 220 }); });
expect(anchorOrigin("top-right", 100, 50, 400, 300, 30)).toEqual({
x: 270,
y: 30,
});
expect(anchorOrigin("bottom-left", 100, 50, 400, 300, 30)).toEqual({
x: 30,
y: 220,
});
expect(anchorOrigin("bottom-right", 100, 50, 400, 300, 30)).toEqual({
x: 270,
y: 220,
});
}); });
it('центрирование — ровно половина остатка', () => { it("центрирование — ровно половина остатка", () => {
expect(anchorOrigin('center', 100, 50, 401, 301, 0)).toEqual({ x: 150.5, y: 125.5 }); expect(anchorOrigin("center", 100, 50, 401, 301, 0)).toEqual({
expect(anchorOrigin('middle-left', 100, 50, 400, 300, 12)).toEqual({ x: 12, y: 125 }); x: 150.5,
expect(anchorOrigin('top-center', 100, 50, 400, 300, 8)).toEqual({ x: 150, y: 8 }); y: 125.5,
});
expect(anchorOrigin("middle-left", 100, 50, 400, 300, 12)).toEqual({
x: 12,
y: 125,
});
expect(anchorOrigin("top-center", 100, 50, 400, 300, 8)).toEqual({
x: 150,
y: 8,
});
}); });
}); });
describe('wrapText', () => { describe("wrapText", () => {
it('жадно набирает строки в пределах ширины', () => { it("жадно набирает строки в пределах ширины", () => {
// measure: 10px за символ → строка ≤ 120px = 12 символов // measure: 10px за символ → строка ≤ 120px = 12 символов
expect(wrapText('один два три четыре пять', 120, measure)).toEqual([ expect(wrapText("один два три четыре пять", 120, measure)).toEqual([
'один два три', "один два три",
'четыре пять' "четыре пять",
]); ]);
}); });
it('слово длиннее ширины уходит на отдельную строку целиком', () => { it("слово длиннее ширины уходит на отдельную строку целиком", () => {
expect(wrapText('короткое сверхдлинноеслово без переносов', 90, measure)).toEqual([ expect(
'короткое', wrapText("короткое сверхдлинноеслово без переносов", 90, measure),
'сверхдлинноеслово', ).toEqual(["короткое", "сверхдлинноеслово", "без", "переносов"]);
'без',
'переносов'
]);
}); });
it('пустой и пробельный текст дают пустой массив', () => { it("пустой и пробельный текст дают пустой массив", () => {
expect(wrapText('', 100, measure)).toEqual([]); expect(wrapText("", 100, measure)).toEqual([]);
expect(wrapText(' \n\t ', 100, measure)).toEqual([]); expect(wrapText(" \n\t ", 100, measure)).toEqual([]);
}); });
}); });
describe('tileGrid', () => { describe("tileGrid", () => {
it('стабильная сетка с шагом и центрированием', () => { it("стабильная сетка с шагом и центрированием", () => {
const pts = tileGrid(200, 200, 0, 60, 60, 80, 24); const pts = tileGrid(200, 200, 0, 60, 60, 80, 24);
expect(pts.length).toBeGreaterThan(0); expect(pts.length).toBeGreaterThan(0);
const xs = new Set(pts.map((p) => p.x)); const xs = new Set(pts.map((p) => p.x));
@@ -52,13 +70,13 @@ describe('tileGrid', () => {
expect(ys.size).toBeGreaterThan(1); expect(ys.size).toBeGreaterThan(1);
}); });
it('кап защищает от гигантского количества плиток', () => { it("кап защищает от гигантского количества плиток", () => {
const pts = tileGrid(4000, 4000, 45, 8, 8, 100, 40); const pts = tileGrid(4000, 4000, 45, 8, 8, 100, 40);
expect(pts.length).toBeLessThanOrEqual(2500); expect(pts.length).toBeLessThanOrEqual(2500);
expect(pts.length).toBeGreaterThan(0); expect(pts.length).toBeGreaterThan(0);
}); });
it('обычные входные данные не триггерят кап', () => { it("обычные входные данные не триггерят кап", () => {
const pts = tileGrid(800, 600, 30, 140, 90, 160, 40); const pts = tileGrid(800, 600, 30, 140, 90, 160, 40);
expect(pts.length).toBeLessThanOrEqual(2500); expect(pts.length).toBeLessThanOrEqual(2500);
expect(pts.length).toBeGreaterThan(4); expect(pts.length).toBeGreaterThan(4);
+40 -23
View File
@@ -1,13 +1,13 @@
export type Position9 = export type Position9 =
| '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";
/** /**
* Левый верхний угол контента размером contentW×contentH при размещении * Левый верхний угол контента размером contentW×contentH при размещении
@@ -19,16 +19,30 @@ export function anchorOrigin(
contentH: number, contentH: number,
cw: number, cw: number,
ch: number, ch: number,
margin: number margin: number,
): { x: number; y: number } { ): { x: number; y: number } {
const h = position.endsWith('-left') ? 'left' : position.endsWith('-right') ? 'right' : 'center'; const h = position.endsWith("-left")
const v = position.startsWith('top-') ? "left"
? 'top' : position.endsWith("-right")
: position.startsWith('bottom-') ? "right"
? 'bottom' : "center";
: 'middle'; const v = position.startsWith("top-")
const x = h === 'left' ? margin : h === 'right' ? cw - margin - contentW : (cw - contentW) / 2; ? "top"
const y = v === 'top' ? margin : v === 'bottom' ? ch - margin - contentH : (ch - contentH) / 2; : position.startsWith("bottom-")
? "bottom"
: "middle";
const x =
h === "left"
? margin
: h === "right"
? cw - margin - contentW
: (cw - contentW) / 2;
const y =
v === "top"
? margin
: v === "bottom"
? ch - margin - contentH
: (ch - contentH) / 2;
return { x, y }; return { x, y };
} }
@@ -39,12 +53,15 @@ export function anchorOrigin(
export function wrapText( export function wrapText(
text: string, text: string,
maxWidth: number, maxWidth: number,
measure: (line: string) => number measure: (line: string) => number,
): string[] { ): string[] {
const words = text.trim().split(/\s+/).filter((w) => w.length > 0); const words = text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0);
if (words.length === 0) return []; if (words.length === 0) return [];
const lines: string[] = []; const lines: string[] = [];
let current = ''; let current = "";
for (const word of words) { for (const word of words) {
const candidate = current.length === 0 ? word : `${current} ${word}`; const candidate = current.length === 0 ? word : `${current} ${word}`;
if (measure(candidate) <= maxWidth || current.length === 0) { if (measure(candidate) <= maxWidth || current.length === 0) {
@@ -75,7 +92,7 @@ export function tileGrid(
stepX: number, stepX: number,
stepY: number, stepY: number,
blockW: number, blockW: number,
blockH: number blockH: number,
): TilePoint[] { ): TilePoint[] {
const diag = Math.sqrt(cw * cw + ch * ch); const diag = Math.sqrt(cw * cw + ch * ch);
const spanX = diag + blockW; const spanX = diag + blockW;
@@ -101,7 +118,7 @@ export function tileGrid(
for (let c = 0; c < cols; c++) { for (let c = 0; c < cols; c++) {
points.push({ points.push({
x: startX + c * sx - cw / 2, x: startX + c * sx - cw / 2,
y: startY + r * sy - ch / 2 y: startY + r * sy - ch / 2,
}); });
} }
} }
+26 -24
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { import {
base64ToBytes, base64ToBytes,
bytesToImage, bytesToImage,
@@ -6,58 +6,60 @@ import {
imageToRgbValues, imageToRgbValues,
looksLikePng, looksLikePng,
rgbValuesToImage, rgbValuesToImage,
stripDataUri stripDataUri,
} from './textio'; } from "./textio";
import { makeImage } from './test-helpers'; import { makeImage } from "./test-helpers";
const img = makeImage(2, 1, [ const img = makeImage(2, 1, [
[255, 0, 0, 255], [255, 0, 0, 255],
[0, 255, 0, 128] [0, 255, 0, 128],
]); ]);
describe('bytes', () => { describe("bytes", () => {
it('round-trip rows → image → rows', () => { it("round-trip rows → image → rows", () => {
const rows = imageToByteRows(img); const rows = imageToByteRows(img);
expect(rows).toBe('255 0 0 255 0 255 0 128'); expect(rows).toBe("255 0 0 255 0 255 0 128");
const back = bytesToImage(rows, 2); const back = bytesToImage(rows, 2);
expect([...back.data]).toEqual([...img.data]); expect([...back.data]).toEqual([...img.data]);
}); });
it('некратное четырём число байт — ошибка', () => { it("некратное четырём число байт — ошибка", () => {
expect(() => bytesToImage('1 2 3', 1)).toThrow(/errors\.bytesCount/); expect(() => bytesToImage("1 2 3", 1)).toThrow(/errors\.bytesCount/);
}); });
it('значение вне 0..255 — ошибка диапазона', () => { it("значение вне 0..255 — ошибка диапазона", () => {
expect(() => bytesToImage('10 20 30 256', 1)).toThrow(/errors\.byteRange/); expect(() => bytesToImage("10 20 30 256", 1)).toThrow(/errors\.byteRange/);
}); });
}); });
describe('rgb values', () => { describe("rgb values", () => {
it('round-trip rgba-строк', () => { it("round-trip rgba-строк", () => {
const rows = imageToRgbValues(img); const rows = imageToRgbValues(img);
expect(rows.split('\n')[0]).toBe('rgba(255, 0, 0, 255) rgba(0, 255, 0, 128)'); expect(rows.split("\n")[0]).toBe(
const back = rgbValuesToImage(rows.replace(/rgba\(|\)/g, ''), 2); "rgba(255, 0, 0, 255) rgba(0, 255, 0, 128)",
);
const back = rgbValuesToImage(rows.replace(/rgba\(|\)/g, ""), 2);
expect([...back.data]).toEqual([...img.data]); expect([...back.data]).toEqual([...img.data]);
}); });
}); });
describe('png signature / data-uri', () => { describe("png signature / data-uri", () => {
it('looksLikePng: настоящая сигнатура и обрывок', () => { it("looksLikePng: настоящая сигнатура и обрывок", () => {
const good = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1]); const good = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1]);
const bad = new Uint8Array([137, 80, 78]); const bad = new Uint8Array([137, 80, 78]);
expect(looksLikePng(good)).toBe(true); expect(looksLikePng(good)).toBe(true);
expect(looksLikePng(bad)).toBe(false); expect(looksLikePng(bad)).toBe(false);
}); });
it('stripDataUri снимает префикс и оставляет чистый base64', () => { it("stripDataUri снимает префикс и оставляет чистый base64", () => {
expect(stripDataUri('data:image/png;base64,iVBORw==')).toBe('iVBORw=='); expect(stripDataUri("data:image/png;base64,iVBORw==")).toBe("iVBORw==");
expect(stripDataUri(' iVBORw==')).toBe('iVBORw=='); expect(stripDataUri(" iVBORw==")).toBe("iVBORw==");
}); });
}); });
describe('base64ToBytes', () => { describe("base64ToBytes", () => {
it('декодирует известную строку', () => { it("декодирует известную строку", () => {
const bytes = base64ToBytes('AAECAwQ='); const bytes = base64ToBytes("AAECAwQ=");
expect([...bytes]).toEqual([0, 1, 2, 3, 4]); expect([...bytes]).toEqual([0, 1, 2, 3, 4]);
}); });
}); });
+29 -19
View File
@@ -1,6 +1,6 @@
import { ToolError } from './errors'; import { ToolError } from "./errors";
import type { PixelImage } from './types'; import type { PixelImage } from "./types";
import { createPixelImage } from './types'; import { createPixelImage } from "./types";
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
@@ -11,25 +11,30 @@ export function imageToByteRows(img: PixelImage): string {
const parts: string[] = []; const parts: string[] = [];
for (let x = 0; x < img.width; x++) { for (let x = 0; x < img.width; x++) {
const i = (y * img.width + x) * 4; const i = (y * img.width + x) * 4;
parts.push(`${img.data[i]} ${img.data[i + 1]} ${img.data[i + 2]} ${img.data[i + 3]}`); parts.push(
`${img.data[i]} ${img.data[i + 1]} ${img.data[i + 2]} ${img.data[i + 3]}`,
);
} }
rows.push(parts.join(' ')); rows.push(parts.join(" "));
} }
return rows.join('\n'); return rows.join("\n");
} }
export function bytesToImage(text: string, width: number): PixelImage { export function bytesToImage(text: string, width: number): PixelImage {
const nums = (text.match(/-?\d+/g) ?? []).map(Number); const nums = (text.match(/-?\d+/g) ?? []).map(Number);
if (nums.length % 4 !== 0) { if (nums.length % 4 !== 0) {
throw new ToolError('errors.bytesCount', { count: nums.length }); throw new ToolError("errors.bytesCount", { count: nums.length });
} }
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
throw new ToolError('errors.byteRange'); throw new ToolError("errors.byteRange");
} }
const w = Math.trunc(width); const w = Math.trunc(width);
if (!Number.isInteger(w) || w < 1) throw new ToolError('errors.widthInt'); if (!Number.isInteger(w) || w < 1) throw new ToolError("errors.widthInt");
if (nums.length / 4 % w !== 0) { if ((nums.length / 4) % w !== 0) {
throw new ToolError('errors.pixelCountMismatch', { count: nums.length / 4, width: w }); throw new ToolError("errors.pixelCountMismatch", {
count: nums.length / 4,
width: w,
});
} }
const out = createPixelImage(w, nums.length / 4 / w); const out = createPixelImage(w, nums.length / 4 / w);
out.data.set(nums); out.data.set(nums);
@@ -43,26 +48,31 @@ export function imageToRgbValues(img: PixelImage): string {
const parts: string[] = []; const parts: string[] = [];
for (let x = 0; x < img.width; x++) { for (let x = 0; x < img.width; x++) {
const i = (y * img.width + x) * 4; const i = (y * img.width + x) * 4;
parts.push(`rgba(${img.data[i]}, ${img.data[i + 1]}, ${img.data[i + 2]}, ${img.data[i + 3]})`); parts.push(
`rgba(${img.data[i]}, ${img.data[i + 1]}, ${img.data[i + 2]}, ${img.data[i + 3]})`,
);
} }
rows.push(parts.join(' ')); rows.push(parts.join(" "));
} }
return rows.join('\n'); return rows.join("\n");
} }
export function rgbValuesToImage(text: string, width: number): PixelImage { export function rgbValuesToImage(text: string, width: number): PixelImage {
const nums = (text.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number); const nums = (text.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
if (nums.length % 4 !== 0) { if (nums.length % 4 !== 0) {
throw new ToolError('errors.bytesCount', { count: nums.length }); throw new ToolError("errors.bytesCount", { count: nums.length });
} }
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
throw new ToolError('errors.byteRange'); throw new ToolError("errors.byteRange");
} }
const w = Math.trunc(width); const w = Math.trunc(width);
if (!Number.isInteger(w) || w < 1) throw new ToolError('errors.widthInt'); if (!Number.isInteger(w) || w < 1) throw new ToolError("errors.widthInt");
const pxCount = nums.length / 4; const pxCount = nums.length / 4;
if (pxCount % w !== 0) { if (pxCount % w !== 0) {
throw new ToolError('errors.pixelCountMismatch', { count: pxCount, width: w }); throw new ToolError("errors.pixelCountMismatch", {
count: pxCount,
width: w,
});
} }
const out = createPixelImage(w, pxCount / w); const out = createPixelImage(w, pxCount / w);
out.data.set(nums); out.data.set(nums);
@@ -76,7 +86,7 @@ export function stripDataUri(text: string): string {
} }
export function base64ToBytes(text: string): Uint8Array { export function base64ToBytes(text: string): Uint8Array {
const clean = text.replace(/\s+/g, ''); const clean = text.replace(/\s+/g, "");
const binary = atob(clean); const binary = atob(clean);
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+1 -1
View File
@@ -8,7 +8,7 @@ export function createPixelImage(width: number, height: number): PixelImage {
return { return {
width, width,
height, height,
data: new Uint8ClampedArray(width * height * 4) data: new Uint8ClampedArray(width * height * 4),
}; };
} }
+8 -6
View File
@@ -1,17 +1,19 @@
import type { CategoryId } from '../categories'; import type { CategoryId } from "../categories";
export const LOCALES = ['ru', 'en'] as const; export const LOCALES = ["ru", "en"] as const;
export type Locale = (typeof LOCALES)[number]; export type Locale = (typeof LOCALES)[number];
export const BASE_LOCALE: Locale = 'en'; export const BASE_LOCALE: Locale = "en";
export const LOCALE_TAGS: Record<Locale, string> = { export const LOCALE_TAGS: Record<Locale, string> = {
ru: 'ru-RU', ru: "ru-RU",
en: 'en-US' en: "en-US",
}; };
export function isLocale(value: unknown): value is Locale { export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && (LOCALES as readonly string[]).includes(value); return (
typeof value === "string" && (LOCALES as readonly string[]).includes(value)
);
} }
export type ToolStrings = { export type ToolStrings = {
+132 -129
View File
@@ -1,182 +1,185 @@
import type { Dict } from './dict'; import type { Dict } from "./dict";
export const en: Dict = { export const en: Dict = {
header: { header: {
workspace: 'Workspace', workspace: "Workspace",
catalog: 'Catalog', catalog: "Catalog",
sectionsAria: 'Sections', sectionsAria: "Sections",
footerNote: footerNote:
'All operations run locally in your browser — your files are never uploaded anywhere.' "All operations run locally in your browser — your files are never uploaded anywhere.",
}, },
categories: { categories: {
convert: 'Convert', convert: "Convert",
alpha: 'Transparency', alpha: "Transparency",
color: 'Color', color: "Color",
geometry: 'Geometry', geometry: "Geometry",
filters: 'Filters', filters: "Filters",
text: 'Text', text: "Text",
analyze: 'Analyze', analyze: "Analyze",
generate: 'Generate' generate: "Generate",
}, },
home: { home: {
defaultTitle: 'easy-png-tools — PNG utilities right in your browser', defaultTitle: "easy-png-tools — PNG utilities right in your browser",
heroTitle: 'What do you want to do with the image?', heroTitle: "What do you want to do with the image?",
heroLead: 'Find a tool — everything runs locally in your browser.', heroLead: "Find a tool — everything runs locally in your browser.",
restoreLast: '↩ Restore last: {title}', restoreLast: "↩ Restore last: {title}",
changeTool: '← Change tool' changeTool: "← Change tool",
}, },
catalog: { catalog: {
pageTitle: 'All tools — easy-png-tools', pageTitle: "All tools — easy-png-tools",
metaDescription: metaDescription:
'Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.', "Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.",
heading: 'Tool catalog', heading: "Tool catalog",
lead: '{count} utilities for working with PNG. Everything runs locally in your browser.' lead: "{count} utilities for working with PNG. Everything runs locally in your browser.",
}, },
toolPage: { toolPage: {
fallbackTitle: 'Tool', fallbackTitle: "Tool",
legendSource: 'Source', legendSource: "Source",
legendSummary: 'Summary', legendSummary: "Summary",
legendResult: 'Result', legendResult: "Result",
legendParams: 'Parameters', legendParams: "Parameters",
stepHeading: 'Step {n}', stepHeading: "Step {n}",
removeStepAria: 'Remove step', removeStepAria: "Remove step",
stepError: 'Step {n} ({title}): {msg}' stepError: "Step {n} ({title}): {msg}",
}, },
chain: { chain: {
stepLabel: 'Step {n}: {title}', stepLabel: "Step {n}: {title}",
inputLegend: 'Input', inputLegend: "Input",
resultLegend: 'Result', resultLegend: "Result",
paramsLegend: 'Parameters', paramsLegend: "Parameters",
busyTitle: 'Processing…', busyTitle: "Processing…",
busyHint: 'Running chain step', busyHint: "Running chain step",
removeStepAria: 'Remove step' removeStepAria: "Remove step",
}, },
sourceCard: { sourceCard: {
replaceImage: 'Replace image' replaceImage: "Replace image",
}, },
resultCard: { resultCard: {
emptyTitle: 'The result will appear here', emptyTitle: "The result will appear here",
emptyHint: 'First upload a source image on the left', emptyHint: "First upload a source image on the left",
processingTitle: 'Processing…', processingTitle: "Processing…",
processingHint: 'The image is being processed, this will take a moment', processingHint: "The image is being processed, this will take a moment",
recalc: 'Recalculating…', recalc: "Recalculating…",
nextTool: '⛓ Next tool', nextTool: "⛓ Next tool",
breakChain: '✂ Break the chain' breakChain: "✂ Break the chain",
}, },
paramsCard: { paramsCard: {
noParams: 'This tool has no parameters — the result is ready as is.' noParams: "This tool has no parameters — the result is ready as is.",
}, },
textInput: { textInput: {
heading: 'Text', heading: "Text",
placeholder: 'Paste data here', placeholder: "Paste data here",
aria: 'Text data', aria: "Text data",
decode: 'Decode' decode: "Decode",
}, },
textResult: { textResult: {
outputAria: 'Text result', outputAria: "Text result",
copied: 'Copied', copied: "Copied",
copy: 'Copy', copy: "Copy",
downloadTxt: 'Download .txt' downloadTxt: "Download .txt",
}, },
download: { download: {
busy: 'Preparing file…', busy: "Preparing file…",
file: 'Download .{ext}' file: "Download .{ext}",
}, },
infoPanel: { infoPanel: {
dimensions: 'Dimensions', dimensions: "Dimensions",
alpha: 'Alpha channel', alpha: "Alpha channel",
alphaYes: 'yes — there are semi-transparent pixels', alphaYes: "yes — there are semi-transparent pixels",
alphaNo: 'no', alphaNo: "no",
colorCount: 'Unique colors (RGBA)' colorCount: "Unique colors (RGBA)",
}, },
dropZone: { dropZone: {
pickDefault: 'Drop an image here or click to choose a file', pickDefault: "Drop an image here or click to choose a file",
overlayDefault: 'Release the file to replace the image' overlayDefault: "Release the file to replace the image",
}, },
search: { search: {
placeholder: 'Find a tool…', placeholder: "Find a tool…",
aria: 'Search tools', aria: "Search tools",
nothingFound: 'Nothing found — try another word.' nothingFound: "Nothing found — try another word.",
}, },
ui: { ui: {
showMask: 'Show mask', showMask: "Show mask",
decrease: 'Decrease', decrease: "Decrease",
increase: 'Increase', increase: "Increase",
reset: 'Reset', reset: "Reset",
pipette: 'Eyedropper', pipette: "Eyedropper",
themeLight: 'Light theme', themeLight: "Light theme",
themeDark: 'Dark theme', themeDark: "Dark theme",
overlayTitle: 'Watermark', overlayTitle: "Watermark",
overlayDrop: 'Drop a watermark PNG or click', overlayDrop: "Drop a watermark PNG or click",
overlayRemove: 'Remove watermark' overlayRemove: "Remove watermark",
}, },
errors: { errors: {
noImageRun: 'This tool does not process images', noImageRun: "This tool does not process images",
workerFailed: 'Worker execution failed', workerFailed: "Worker execution failed",
workerUnavailable: 'Worker is unavailable', workerUnavailable: "Worker is unavailable",
notFound: 'Tool not found', notFound: "Tool not found",
noWatermark: 'Pick a watermark image first', noWatermark: "Pick a watermark image first",
badTransform: 'Degenerate transformation matrix', badTransform: "Degenerate transformation matrix",
skewAngle: 'Skew angles cannot be 90° or -90°', skewAngle: "Skew angles cannot be 90° or -90°",
badHex: 'Invalid HEX color: "{value}"', badHex: 'Invalid HEX color: "{value}"',
radiusInt: 'Radius must be a non-negative integer', radiusInt: "Radius must be a non-negative integer",
kernelSize: 'Kernel does not match the image dimensions', kernelSize: "Kernel does not match the image dimensions",
sizeInt: 'Width and height must be integers ≥ 1', sizeInt: "Width and height must be integers ≥ 1",
cropBounds: 'Crop area does not intersect the image', cropBounds: "Crop area does not intersect the image",
noCanvasCtx: 'Canvas 2D context is unavailable in this environment', noCanvasCtx: "Canvas 2D context is unavailable in this environment",
badBase64: 'Expected a base64 string or a data-uri of an image', badBase64: "Expected a base64 string or a data-uri of an image",
qualityRange: 'quality must be within 0..1', qualityRange: "quality must be within 0..1",
svgSize: 'Could not determine SVG dimensions', svgSize: "Could not determine SVG dimensions",
svgLoad: 'Failed to load SVG — check the markup', svgLoad: "Failed to load SVG — check the markup",
encodeUnsupported: 'The browser does not support encoding to {mime}', encodeUnsupported: "The browser does not support encoding to {mime}",
unsupportedFile: unsupportedFile:
'Unsupported file format ({type}). Supported formats are PNG, JPEG, WebP, GIF and BMP.', "Unsupported file format ({type}). Supported formats are PNG, JPEG, WebP, GIF and BMP.",
widthInt: 'Image width must be an integer ≥ 1', widthInt: "Image width must be an integer ≥ 1",
noHexPixels: 'No hex pixel values found', noHexPixels: "No hex pixel values found",
badPixelToken: 'Each pixel must be 8 hex characters RRGGBBAA, separated by spaces', badPixelToken:
pixelCountMismatch: 'Pixel count ({count}) is not divisible by width {width} without a remainder', "Each pixel must be 8 hex characters RRGGBBAA, separated by spaces",
pixelCountMismatch:
"Pixel count ({count}) is not divisible by width {width} without a remainder",
toolNotFound: 'Tool "{id}" not found', toolNotFound: 'Tool "{id}" not found',
badJson: 'The file is not valid JSON', badJson: "The file is not valid JSON",
badPipelineShape: 'The file structure does not look like a chain of steps', badPipelineShape: "The file structure does not look like a chain of steps",
pipelineVersion: 'Unsupported chain version: {version}', pipelineVersion: "Unsupported chain version: {version}",
noSteps: 'The file has no list of steps', noSteps: "The file has no list of steps",
paramNumber: 'Parameter "{id}" must be a number', paramNumber: 'Parameter "{id}" must be a number',
paramString: 'Parameter "{id}" must be a string', paramString: 'Parameter "{id}" must be a string',
paramBool: 'Parameter "{id}" must be a checkbox value', paramBool: 'Parameter "{id}" must be a checkbox value',
resizeSize: 'Width and/or height must be positive', resizeSize: "Width and/or height must be positive",
cropSize: 'Crop width and height must be positive', cropSize: "Crop width and height must be positive",
sizePositive: 'Dimensions must be positive and finite' sizePositive: "Dimensions must be positive and finite",
}, },
tools: { tools: {
'png-file-size': { "png-file-size": {
results: { results: {
line: 'PNG size: {kb} KB' line: "PNG size: {kb} KB",
}
}, },
'verify-is-png': {
results: {
verifyYes: 'Yes — this is a valid PNG signature.',
verifyNo: 'No — the signature does not match a PNG file.'
}
}, },
'png-is-grayscale': { "verify-is-png": {
results: { results: {
grayscaleYes: 'Yes — all pixels are shades of gray.', verifyYes: "Yes — this is a valid PNG signature.",
grayscaleNo: 'No — colored pixels were found.' verifyNo: "No — the signature does not match a PNG file.",
}
}, },
'png-is-transparent': {
results: {
transparentYes: 'Yes — there are transparent or semi-transparent pixels.',
transparentNo: 'No — all pixels are fully opaque.'
}
}, },
'png-orientation': { "png-is-grayscale": {
results: { results: {
orientationPortrait: 'Portrait — height is greater than width.', grayscaleYes: "Yes — all pixels are shades of gray.",
orientationLandscape: 'Landscape — width is greater than height.', grayscaleNo: "No — colored pixels were found.",
orientationSquare: 'Square — the sides are equal.' },
} },
} "png-is-transparent": {
} results: {
transparentYes:
"Yes — there are transparent or semi-transparent pixels.",
transparentNo: "No — all pixels are fully opaque.",
},
},
"png-orientation": {
results: {
orientationPortrait: "Portrait — height is greater than width.",
orientationLandscape: "Landscape — width is greater than height.",
orientationSquare: "Square — the sides are equal.",
},
},
},
}; };
+39 -37
View File
@@ -1,80 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from "vitest";
import { getLocale, initLocale, setLocale } from './locale.svelte'; import { getLocale, initLocale, setLocale } from "./locale.svelte";
import { interpolate, t } from './t'; import { interpolate, t } from "./t";
import { en } from './en'; import { en } from "./en";
type Store = Record<string, string>; type Store = Record<string, string>;
function stubStorage(): { store: Store } { function stubStorage(): { store: Store } {
const store: Store = {}; const store: Store = {};
vi.stubGlobal('localStorage', { vi.stubGlobal("localStorage", {
getItem: (k: string) => (k in store ? store[k] : null), getItem: (k: string) => (k in store ? store[k] : null),
setItem: (k: string, v: string) => { setItem: (k: string, v: string) => {
store[k] = v; store[k] = v;
} },
}); });
vi.stubGlobal('window', {}); vi.stubGlobal("window", {});
return { store }; return { store };
} }
beforeEach(() => { beforeEach(() => {
setLocale('ru'); setLocale("ru");
delete (en.home as Record<string, string>).onlyEnKey; delete (en.home as Record<string, string>).onlyEnKey;
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe('t', () => { describe("t", () => {
it('возвращает строку по точечному пути активной локали', () => { it("возвращает строку по точечному пути активной локали", () => {
expect(t('header.workspace')).toBe('Рабочая область'); expect(t("header.workspace")).toBe("Рабочая область");
setLocale('en'); setLocale("en");
expect(t('header.catalog')).toBe('Catalog'); expect(t("header.catalog")).toBe("Catalog");
}); });
it('фолбэк на английскую базу, если в активной локали нет ключа', () => { it("фолбэк на английскую базу, если в активной локали нет ключа", () => {
(en.home as Record<string, string>).onlyEnKey = 'Only English string'; (en.home as Record<string, string>).onlyEnKey = "Only English string";
setLocale('ru'); setLocale("ru");
expect(t('home.onlyEnKey')).toBe('Only English string'); expect(t("home.onlyEnKey")).toBe("Only English string");
}); });
it('неизвестный путь возвращает сам путь', () => { it("неизвестный путь возвращает сам путь", () => {
expect(t('no.such.key')).toBe('no.such.key'); expect(t("no.such.key")).toBe("no.such.key");
}); });
}); });
describe('interpolate', () => { describe("interpolate", () => {
it('подставляет переменные в шаблон', () => { it("подставляет переменные в шаблон", () => {
expect(interpolate('Шаг {n} из {total}', { n: 2, total: 5 })).toBe('Шаг 2 из 5'); expect(interpolate("Шаг {n} из {total}", { n: 2, total: 5 })).toBe(
"Шаг 2 из 5",
);
}); });
it('оставляет плейсхолдер без переменной как есть', () => { it("оставляет плейсхолдер без переменной как есть", () => {
expect(interpolate('Привет, {name}!', {})).toBe('Привет, {name}!'); expect(interpolate("Привет, {name}!", {})).toBe("Привет, {name}!");
}); });
it('без переменных возвращает строку без изменений', () => { it("без переменных возвращает строку без изменений", () => {
expect(interpolate('Просто текст')).toBe('Просто текст'); expect(interpolate("Просто текст")).toBe("Просто текст");
}); });
}); });
describe('персист локали', () => { describe("персист локали", () => {
it('initLocale читает сохранённый выбор', () => { it("initLocale читает сохранённый выбор", () => {
const { store } = stubStorage(); const { store } = stubStorage();
store['locale'] = 'en'; store["locale"] = "en";
initLocale(); initLocale();
expect(getLocale()).toBe('en'); expect(getLocale()).toBe("en");
}); });
it('initLocale игнорирует мусор в хранилище', () => { it("initLocale игнорирует мусор в хранилище", () => {
const { store } = stubStorage(); const { store } = stubStorage();
store['locale'] = 'fr'; store["locale"] = "fr";
initLocale(); initLocale();
expect(getLocale()).toBe('ru'); expect(getLocale()).toBe("ru");
}); });
it('setLocale сохраняет выбор в localStorage', () => { it("setLocale сохраняет выбор в localStorage", () => {
const { store } = stubStorage(); const { store } = stubStorage();
initLocale(); initLocale();
setLocale('en'); setLocale("en");
expect(store['locale']).toBe('en'); expect(store["locale"]).toBe("en");
expect(getLocale()).toBe('en'); expect(getLocale()).toBe("en");
}); });
}); });
+9 -11
View File
@@ -1,15 +1,15 @@
import { BASE_LOCALE, isLocale, type Dict, type Locale } from './dict'; import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict";
import { ru } from './ru'; import { ru } from "./ru";
import { en } from './en'; import { en } from "./en";
const STORAGE_KEY = 'locale'; const STORAGE_KEY = "locale";
const DICTS: Record<Locale, Dict> = { ru, en }; const DICTS: Record<Locale, Dict> = { ru, en };
let locale = $state<Locale>(BASE_LOCALE); let locale = $state<Locale>(BASE_LOCALE);
function storage(): Storage | null { function storage(): Storage | null {
return typeof localStorage === 'undefined' ? null : localStorage; return typeof localStorage === "undefined" ? null : localStorage;
} }
export function getLocale(): Locale { export function getLocale(): Locale {
@@ -29,13 +29,11 @@ export function getMergedDict(): Dict {
get(_target, section: string) { get(_target, section: string) {
const a = (active as unknown as Record<string, unknown>)[section]; const a = (active as unknown as Record<string, unknown>)[section];
const b = (base as unknown as Record<string, unknown>)[section]; const b = (base as unknown as Record<string, unknown>)[section];
if ( if (a && b && typeof a === "object" && typeof b === "object") {
a && b && typeof a === 'object' && typeof b === 'object'
) {
return { ...(b as object), ...(a as object) }; return { ...(b as object), ...(a as object) };
} }
return a ?? b; return a ?? b;
} },
}) as Dict; }) as Dict;
} }
@@ -47,14 +45,14 @@ export function setLocale(next: Locale): void {
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */ /** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
export function initLocale(): void { export function initLocale(): void {
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
const saved = storage()?.getItem(STORAGE_KEY); const saved = storage()?.getItem(STORAGE_KEY);
if (isLocale(saved)) locale = saved; if (isLocale(saved)) locale = saved;
syncLangAttr(); syncLangAttr();
} }
function syncLangAttr(): void { function syncLangAttr(): void {
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
document.documentElement.lang = locale; document.documentElement.lang = locale;
} }
} }
+53 -46
View File
@@ -1,94 +1,101 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { TOOLS } from '../registry'; import { TOOLS } from "../registry";
import { normalizeForSearch, scoreDoc, type SearchDoc } from './matching'; import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
import { setLocale } from './locale.svelte'; import { setLocale } from "./locale.svelte";
import { toolSearchDoc } from './tool-strings'; import { toolSearchDoc } from "./tool-strings";
describe('normalizeForSearch', () => { describe("normalizeForSearch", () => {
it('нижний регистр', () => { it("нижний регистр", () => {
expect(normalizeForSearch('Rotate PNG')).toBe('rotate png'); expect(normalizeForSearch("Rotate PNG")).toBe("rotate png");
}); });
it('ё заменяется на е', () => { it("ё заменяется на е", () => {
expect(normalizeForSearch('Ёлка и ёж')).toBe('елка и еж'); expect(normalizeForSearch("Ёлка и ёж")).toBe("елка и еж");
}); });
it('диакритика снимается через NFD', () => { it("диакритика снимается через NFD", () => {
expect(normalizeForSearch('café')).toBe('cafe'); expect(normalizeForSearch("café")).toBe("cafe");
expect(normalizeForSearch('Über')).toBe('uber'); expect(normalizeForSearch("Über")).toBe("uber");
}); });
}); });
describe('scoreDoc', () => { describe("scoreDoc", () => {
const doc: SearchDoc = { const doc: SearchDoc = {
id: 'rotate-free-png', id: "rotate-free-png",
titles: ['Повернуть на произвольный угол'], titles: ["Повернуть на произвольный угол"],
descriptions: ['Поворот на любой угол. Холст расширяется под новые габариты.'] descriptions: [
"Поворот на любой угол. Холст расширяется под новые габариты.",
],
}; };
it('пустой запрос даёт нейтральный балл', () => { it("пустой запрос даёт нейтральный балл", () => {
expect(scoreDoc(doc, '')).toBe(1); expect(scoreDoc(doc, "")).toBe(1);
}); });
it('префикс названия ценнее подстроки', () => { it("префикс названия ценнее подстроки", () => {
const prefix = scoreDoc(doc, normalizeForSearch('повер')); const prefix = scoreDoc(doc, normalizeForSearch("повер"));
const infix = scoreDoc(doc, normalizeForSearch('верну')); const infix = scoreDoc(doc, normalizeForSearch("верну"));
expect(prefix!).toBe(100); expect(prefix!).toBe(100);
expect(infix!).toBeGreaterThan(30); expect(infix!).toBeGreaterThan(30);
}); });
it('подстрока названия ценнее совпадения в описании', () => { it("подстрока названия ценнее совпадения в описании", () => {
const title = scoreDoc(doc, normalizeForSearch('угол')); const title = scoreDoc(doc, normalizeForSearch("угол"));
const desc = scoreDoc(doc, normalizeForSearch('габариты')); const desc = scoreDoc(doc, normalizeForSearch("габариты"));
expect(title!).toBeGreaterThan(desc!); expect(title!).toBeGreaterThan(desc!);
}); });
it('нет совпадения — null', () => { it("нет совпадения — null", () => {
expect(scoreDoc(doc, normalizeForSearch('квант'))).toBeNull(); expect(scoreDoc(doc, normalizeForSearch("квант"))).toBeNull();
}); });
it('запрос с опечаткой ё/е всё равно находит', () => { it("запрос с опечаткой ё/е всё равно находит", () => {
expect(scoreDoc(doc, normalizeForSearch('повёрнут'))).not.toBeNull(); expect(scoreDoc(doc, normalizeForSearch("повёрнут"))).not.toBeNull();
}); });
}); });
describe('кросс-языковой поиск на реальном реестре', () => { describe("кросс-языковой поиск на реальном реестре", () => {
it('английский запрос находит инструмент при русской локали', () => { it("английский запрос находит инструмент при русской локали", () => {
setLocale('ru'); setLocale("ru");
const hits = TOOLS.filter((tool) => { const hits = TOOLS.filter((tool) => {
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch('rotate')); const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch("rotate"));
return s !== null && s > 0; return s !== null && s > 0;
}).map((tool) => tool.id); }).map((tool) => tool.id);
expect(hits).toContain('rotate-png'); expect(hits).toContain("rotate-png");
expect(hits).toContain('rotate-free-png'); expect(hits).toContain("rotate-free-png");
}); });
it('русский запрос находит инструмент при английской локали', () => { it("русский запрос находит инструмент при английской локали", () => {
setLocale('en'); setLocale("en");
try { try {
for (const q of ['повернуть', 'пово']) { for (const q of ["повернуть", "пово"]) {
const hits = TOOLS.filter((tool) => { const hits = TOOLS.filter((tool) => {
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q)); const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
return s !== null && s > 0; return s !== null && s > 0;
}).map((tool) => tool.id); }).map((tool) => tool.id);
expect(hits, `запрос «${q}» при en-локали`).toContain('rotate-png'); expect(hits, `запрос «${q}» при en-локали`).toContain("rotate-png");
expect(hits, `запрос «${q}» при en-локали`).toContain('rotate-free-png'); expect(hits, `запрос «${q}» при en-локали`).toContain(
"rotate-free-png",
);
} }
} finally { } finally {
setLocale('ru'); setLocale("ru");
} }
}); });
it('поиск работает и по описанию, не только по названию', () => { it("поиск работает и по описанию, не только по названию", () => {
setLocale('en'); setLocale("en");
try { try {
const hits = TOOLS.filter((tool) => { const hits = TOOLS.filter((tool) => {
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch('полупрозрачные')); const s = scoreDoc(
toolSearchDoc(tool),
normalizeForSearch("полупрозрачные"),
);
return s !== null && s > 0; return s !== null && s > 0;
}).map((tool) => tool.id); }).map((tool) => tool.id);
expect(hits).toContain('png-is-transparent'); expect(hits).toContain("png-is-transparent");
} finally { } finally {
setLocale('ru'); setLocale("ru");
} }
}); });
}); });
+7 -4
View File
@@ -4,9 +4,9 @@
export function normalizeForSearch(value: string): string { export function normalizeForSearch(value: string): string {
return value return value
.toLowerCase() .toLowerCase()
.replaceAll('ё', 'е') .replaceAll("ё", "е")
.normalize('NFD') .normalize("NFD")
.replace(/\p{M}/gu, ''); .replace(/\p{M}/gu, "");
} }
export type SearchDoc = { export type SearchDoc = {
@@ -48,7 +48,10 @@ function bestTitleScore(titles: string[], q: string): number | null {
* Скоринг документа против нормализованного запроса. * Скоринг документа против нормализованного запроса.
* Пустой запрос нейтральный балл; отсутствие совпадения null. * Пустой запрос нейтральный балл; отсутствие совпадения null.
*/ */
export function scoreDoc(doc: SearchDoc, normalizedQuery: string): number | null { export function scoreDoc(
doc: SearchDoc,
normalizedQuery: string,
): number | null {
if (normalizedQuery.length === 0) return 1; if (normalizedQuery.length === 0) return 1;
if (normalizeForSearch(doc.id).includes(normalizedQuery)) { if (normalizeForSearch(doc.id).includes(normalizedQuery)) {
const at = normalizeForSearch(doc.id).indexOf(normalizedQuery); const at = normalizeForSearch(doc.id).indexOf(normalizedQuery);
+1130 -931
View File
File diff suppressed because it is too large Load Diff
+13 -11
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { TOOLS } from '../registry'; import { TOOLS } from "../registry";
import { normalizeForSearch, scoreDoc } from './matching'; import { normalizeForSearch, scoreDoc } from "./matching";
import { toolSearchDoc } from './tool-strings'; import { toolSearchDoc } from "./tool-strings";
import { isChainable } from '../registry'; import { isChainable } from "../registry";
function hits(q: string): string[] { function hits(q: string): string[] {
const nq = normalizeForSearch(q); const nq = normalizeForSearch(q);
@@ -12,13 +12,15 @@ function hits(q: string): string[] {
}).map((t) => t.id); }).map((t) => t.id);
} }
describe('полнота поиска', () => { describe("полнота поиска", () => {
it('генератор (не chainable) находится поиском — раньше отфильтровывался', () => { it("генератор (не chainable) находится поиском — раньше отфильтровывался", () => {
expect(isChainable(TOOLS.find((t) => t.id === 'color-wheel-png')!)).toBe(false); expect(isChainable(TOOLS.find((t) => t.id === "color-wheel-png")!)).toBe(
expect(hits('color wheel')).toContain('color-wheel-png'); false,
);
expect(hits("color wheel")).toContain("color-wheel-png");
}); });
it('анализатор-маска тоже ищется', () => { it("анализатор-маска тоже ищется", () => {
expect(hits('уникальных цветов')).toContain('unique-color-mask-png'); expect(hits("уникальных цветов")).toContain("unique-color-mask-png");
}); });
}); });
+66 -52
View File
@@ -1,82 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from "vitest";
import { setLocale } from './locale.svelte'; import { setLocale } from "./locale.svelte";
import { t } from './t'; import { t } from "./t";
import { LOCALE_TAGS } from './dict'; import { LOCALE_TAGS } from "./dict";
import { normalizeForSearch, scoreDoc } from './matching'; import { normalizeForSearch, scoreDoc } from "./matching";
import { toolSearchDoc } from './tool-strings'; import { toolSearchDoc } from "./tool-strings";
import { TOOLS } from '../registry'; import { TOOLS } from "../registry";
afterEach(() => { afterEach(() => {
setLocale('ru'); setLocale("ru");
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe('Смоук §6 i18n', () => { describe("Смоук §6 i18n", () => {
it('1. html lang следует за локалью', () => { it("1. html lang следует за локалью", () => {
const doc = { documentElement: { lang: '' } }; const doc = { documentElement: { lang: "" } };
vi.stubGlobal('document', doc); vi.stubGlobal("document", doc);
setLocale('en'); setLocale("en");
expect(doc.documentElement.lang).toBe('en'); expect(doc.documentElement.lang).toBe("en");
setLocale('ru'); setLocale("ru");
expect(doc.documentElement.lang).toBe('ru'); expect(doc.documentElement.lang).toBe("ru");
}); });
it('3. Ключевые секции переведены без смеси языков', () => { it("3. Ключевые секции переведены без смеси языков", () => {
const samples: Array<[string, string, string]> = [ const samples: Array<[string, string, string]> = [
['header.workspace', 'Рабочая область', 'Workspace'], ["header.workspace", "Рабочая область", "Workspace"],
['catalog.heading', 'Каталог инструментов', 'Tool catalog'], ["catalog.heading", "Каталог инструментов", "Tool catalog"],
['home.heroTitle', 'Что делаем с изображением?', 'What do you want to do'], [
['chain.inputLegend', 'Вход', 'Input'], "home.heroTitle",
['resultCard.nextTool', 'Следующий инструмент', 'Next tool'], "Что делаем с изображением?",
['download.busy', 'Готовим файл', 'Preparing file'] "What do you want to do",
],
["chain.inputLegend", "Вход", "Input"],
["resultCard.nextTool", "Следующий инструмент", "Next tool"],
["download.busy", "Готовим файл", "Preparing file"],
]; ];
for (const [key, ruPart, enPart] of samples) { for (const [key, ruPart, enPart] of samples) {
setLocale('ru'); setLocale("ru");
expect(t(key), key + ' @ru').toContain(ruPart); expect(t(key), key + " @ru").toContain(ruPart);
setLocale('en'); setLocale("en");
expect(t(key), key + ' @en').toContain(enPart); expect(t(key), key + " @en").toContain(enPart);
} }
}); });
it('5. Ошибки с vars локализуются на оба языка', () => { it("5. Ошибки с vars локализуются на оба языка", () => {
setLocale('ru'); setLocale("ru");
expect(t('errors.badHex', { value: '#zz' })).toBe('Некорректный HEX-цвет: "#zz"'); expect(t("errors.badHex", { value: "#zz" })).toBe(
expect(t('errors.toolNotFound', { id: 'x' })).toContain('не найден'); 'Некорректный HEX-цвет: "#zz"',
setLocale('en'); );
expect(t('errors.badHex', { value: '#zz' })).toBe('Invalid HEX color: "#zz"'); expect(t("errors.toolNotFound", { id: "x" })).toContain("не найден");
setLocale("en");
expect(t("errors.badHex", { value: "#zz" })).toBe(
'Invalid HEX color: "#zz"',
);
}); });
it('6. Поиск кросс-языковой в обе стороны', () => { it("6. Поиск кросс-языковой в обе стороны", () => {
const ids = (q: string) => const ids = (q: string) =>
TOOLS.filter((tool) => { TOOLS.filter((tool) => {
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q)); const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
return s !== null && s > 0; return s !== null && s > 0;
}).map((tool) => tool.id); }).map((tool) => tool.id);
setLocale('ru'); setLocale("ru");
let hits = ids('rotate'); let hits = ids("rotate");
expect(hits).toContain('rotate-png'); expect(hits).toContain("rotate-png");
hits = ids('повер'); hits = ids("повер");
expect(hits).toContain('rotate-png'); expect(hits).toContain("rotate-png");
setLocale('en'); setLocale("en");
hits = ids('пово'); hits = ids("пово");
expect(hits).toContain('rotate-free-png'); expect(hits).toContain("rotate-free-png");
hits = ids('rotate'); hits = ids("rotate");
expect(hits).toContain('rotate-png'); expect(hits).toContain("rotate-png");
}); });
it('6b. Ё не мешает совпадению', () => { it("6b. Ё не мешает совпадению", () => {
const blackWhite = TOOLS.find((tool) => tool.id === 'black-and-white-png')!; const blackWhite = TOOLS.find((tool) => tool.id === "black-and-white-png")!;
setLocale('ru'); setLocale("ru");
const hit = scoreDoc(toolSearchDoc(blackWhite), normalizeForSearch('ЧЁРНО')); const hit = scoreDoc(
toolSearchDoc(blackWhite),
normalizeForSearch("ЧЁРНО"),
);
expect(hit).not.toBeNull(); expect(hit).not.toBeNull();
const withoutYo = scoreDoc(toolSearchDoc(blackWhite), normalizeForSearch('черно')); const withoutYo = scoreDoc(
toolSearchDoc(blackWhite),
normalizeForSearch("черно"),
);
expect(withoutYo).not.toBeNull(); expect(withoutYo).not.toBeNull();
}); });
it('7. Теги локалей для форматирования чисел корректны', () => { it("7. Теги локалей для форматирования чисел корректны", () => {
expect(LOCALE_TAGS.ru).toBe('ru-RU'); expect(LOCALE_TAGS.ru).toBe("ru-RU");
expect(LOCALE_TAGS.en).toBe('en-US'); expect(LOCALE_TAGS.en).toBe("en-US");
}); });
}); });
+17 -7
View File
@@ -1,9 +1,12 @@
import { getMergedDict } from './locale.svelte'; import { getMergedDict } from "./locale.svelte";
export function interpolate(template: string, vars?: Record<string, string | number>): string { export function interpolate(
template: string,
vars?: Record<string, string | number>,
): string {
if (!vars) return template; if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (match, name: string) => return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in vars ? String(vars[name]) : match name in vars ? String(vars[name]) : match,
); );
} }
@@ -11,14 +14,21 @@ export function interpolate(template: string, vars?: Record<string, string | num
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'. * Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
* Сначала активная локаль, затем базовая; если ключа нет нигде возвращается сам путь. * Сначала активная локаль, затем базовая; если ключа нет нигде возвращается сам путь.
*/ */
export function t(path: string, vars?: Record<string, string | number>): string { export function t(
path: string,
vars?: Record<string, string | number>,
): string {
let node: unknown = getMergedDict(); let node: unknown = getMergedDict();
for (const part of path.split('.')) { for (const part of path.split(".")) {
if (node && typeof node === 'object' && part in (node as Record<string, unknown>)) { if (
node &&
typeof node === "object" &&
part in (node as Record<string, unknown>)
) {
node = (node as Record<string, unknown>)[part]; node = (node as Record<string, unknown>)[part];
} else { } else {
return path; return path;
} }
} }
return typeof node === 'string' ? interpolate(node, vars) : path; return typeof node === "string" ? interpolate(node, vars) : path;
} }
+19 -11
View File
@@ -1,7 +1,7 @@
import type { ParamDef, ToolEntry } from '$lib/registry'; import type { ParamDef, ToolEntry } from "$lib/registry";
import type { SearchDoc } from './matching'; import type { SearchDoc } from "./matching";
import { getMergedDict } from './locale.svelte'; import { getMergedDict } from "./locale.svelte";
import { ru } from './ru'; import { ru } from "./ru";
/** /**
* Строки инструмента на активной локали. * Строки инструмента на активной локали.
@@ -23,10 +23,14 @@ export function paramLabel(tool: ToolEntry, param: ParamDef): string {
return viaDef.label ?? param.id; return viaDef.label ?? param.id;
} }
export function optionLabel(tool: ToolEntry, param: ParamDef, value: string): string { export function optionLabel(
tool: ToolEntry,
param: ParamDef,
value: string,
): string {
const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value]; const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value];
if (viaDict) return viaDict; if (viaDict) return viaDict;
if (param.type === 'select') { if (param.type === "select") {
return param.options.find((o) => o.value === value)?.label ?? value; return param.options.find((o) => o.value === value)?.label ?? value;
} }
return value; return value;
@@ -45,11 +49,15 @@ export function toolSearchDoc(tool: ToolEntry): SearchDoc {
const active = getMergedDict().tools[tool.id]; const active = getMergedDict().tools[tool.id];
return { return {
id: tool.id, id: tool.id,
titles: dedupe([active?.title ?? '', ru.tools[tool.id]?.title ?? '', tool.title]), titles: dedupe([
active?.title ?? "",
ru.tools[tool.id]?.title ?? "",
tool.title,
]),
descriptions: dedupe([ descriptions: dedupe([
active?.description ?? '', active?.description ?? "",
ru.tools[tool.id]?.description ?? '', ru.tools[tool.id]?.description ?? "",
tool.description tool.description,
]) ]),
}; };
} }

Some files were not shown because too many files have changed in this diff Show More