feat: separate slug pages from tools

This commit is contained in:
2026-09-27 16:10:28 +05:00
parent 931ee27b05
commit 7a3498c173
35 changed files with 1436 additions and 993 deletions
+6 -6
View File
@@ -1,17 +1,17 @@
import { CATEGORY_IDS } from "./categories";
import { TOOLS, type ToolEntry } from "./registry";
import { PAGES, type Page } from "./registry";
export type PreviewGroup = {
id: string;
tools: ToolEntry[];
pages: Page[];
};
/**
* Preview-каталог: полный реестр инструментов, сгруппированный по категориям.
* Preview-каталог: все страницы, сгруппированные по категориям.
*/
export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
id: category,
tools: TOOLS.filter((tool) => tool.category === category),
})).filter((group) => group.tools.length > 0);
pages: PAGES.filter((page) => page.category === category),
})).filter((group) => group.pages.length > 0);
export const PREVIEW_TOTAL = TOOLS.length;
export const PREVIEW_TOTAL = PAGES.length;
+11 -10
View File
@@ -7,12 +7,12 @@
import type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor";
import {
toolDescription,
toolTitle,
pageDescription,
pageTitle,
verdictText,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import type { FileResult, ToolEntry } from "$lib/registry";
import type { FileResult, Page, Tool } from "$lib/registry";
import {
applySourceDefaults,
defaultSchemaParams,
@@ -22,12 +22,13 @@
import { downloadZip } from "$lib/zip";
interface Props {
tool: ToolEntry;
page: Page;
tool: Tool;
}
type DisplayError =
| { kind: "i18n"; key: string; vars?: ErrorVars }
| { kind: "plain"; text: string };
let { tool }: Props = $props();
let { page, tool }: Props = $props();
const schema = $derived(
tool.schema as ToolSchema<Record<string, unknown>> | undefined,
@@ -141,7 +142,7 @@
try {
if (resultKind === "files") {
if (!fileResult) return;
await downloadZip(fileResult.files, `${tool.id}.zip`);
await downloadZip(fileResult.files, `${page.slug}.zip`);
return;
}
if (!result) return;
@@ -150,7 +151,7 @@
? Number(values[out.qualityParamId]) / 100
: undefined;
const blob = await encode(result, out?.mime ?? "image/png", quality);
downloadBlob(blob, `${tool.id}.${out?.ext ?? "png"}`);
downloadBlob(blob, `${page.slug}.${out?.ext ?? "png"}`);
} catch (e) {
displayError = toDisplayError(e);
} finally {
@@ -175,7 +176,7 @@
if (!textResult) return;
try {
const blob = new Blob([textResult], { type: "text/plain" });
downloadBlob(blob, `${tool.id}.txt`);
downloadBlob(blob, `${page.slug}.txt`);
} catch (e) {
displayError = toDisplayError(e);
}
@@ -208,8 +209,8 @@
<div class="schema-tool">
<header class="header">
<div class="title-block">
<h1>{toolTitle(tool)}</h1>
<p class="lede">{toolDescription(tool)}</p>
<h1>{pageTitle(page)}</h1>
<p class="lede">{pageDescription(page)}</p>
</div>
</header>
+3 -3
View File
@@ -6,15 +6,15 @@
interface Props {
title: string;
id: string;
slug: string;
description?: string;
index?: number;
icon?: Component<{ size?: number; class?: string }>;
}
let { title, id, description, index, icon }: Props = $props();
let { title, slug, description, index, icon }: Props = $props();
</script>
<a class="tool-card" href={resolve("/tools/[id]", { id })}>
<a class="tool-card" href={resolve("/tools/[id]", { id: slug })}>
{#if icon}<span class="tool-icon"><Icon {icon} size={19} /></span>{/if}
<span class="tool-copy">
<strong>{title}</strong>
+4 -8
View File
@@ -15,7 +15,7 @@ import {
} from "./protocol";
import { handleWorkerRequest } from "./worker-handler";
import { field, toolSchema, type Dimension } from "../registry-schema";
import type { ToolEntry } from "../registry";
import type { Tool } from "../registry";
class FakeWorker implements WorkerLike {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
@@ -61,15 +61,11 @@ function createFactory(throwOnPost = false): {
const emptySchema = toolSchema<Record<string, never>>({});
function makeTool(
run: ToolEntry["run"] = () => "direct",
overrides: Partial<ToolEntry> = {},
): ToolEntry {
run: Tool["run"] = () => "direct",
overrides: Partial<Tool> = {},
): Tool {
return {
id: "test-tool",
slug: "test-tool",
title: "Test tool",
description: "Test tool",
category: "analyze",
schema: emptySchema,
input: "none",
run,
+5 -8
View File
@@ -1,6 +1,6 @@
import { ToolError } from "../core/errors";
import type { PixelImage } from "../core/types";
import type { ToolContext, ToolEntry, ToolResult } from "../registry";
import type { ToolContext, Tool, ToolResult } from "../registry";
import { sanitizeSchemaParams } from "../registry-schema";
import {
decodeWorkerResponse,
@@ -37,7 +37,7 @@ function defaultWorkerFactory(): WorkerLike {
export function createExecutor(
options: { workerFactory?: WorkerFactory } = {},
): (tool: ToolEntry, ctx: ExecuteContext) => Promise<ToolResult> {
): (tool: Tool, ctx: ExecuteContext) => Promise<ToolResult> {
const workerFactory = options.workerFactory ?? defaultWorkerFactory;
let worker: WorkerLike | null = null;
let workerTried = false;
@@ -102,7 +102,7 @@ export function createExecutor(
}
async function runDirect(
tool: ToolEntry,
tool: Tool,
ctx: ExecuteContext,
): Promise<ToolResult> {
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
@@ -110,7 +110,7 @@ export function createExecutor(
function runInWorker(
candidate: WorkerLike,
tool: ToolEntry,
tool: Tool,
ctx: ExecuteContext,
): Promise<ToolResult> {
return new Promise((resolve, reject) => {
@@ -136,10 +136,7 @@ export function createExecutor(
});
}
async function route(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
async function route(tool: Tool, ctx: ExecuteContext): Promise<ToolResult> {
if (tool.domOnly) return await runDirect(tool, ctx);
const candidate = ensureWorker();
if (candidate === null) return await runDirect(tool, ctx);
+18 -30
View File
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import { TOOLS } from "../registry";
import { PAGES } from "../registry";
import { setLocale } from "./locale.svelte";
import {
searchTools,
toolDescription,
toolTitle,
pageDescription,
pageTitle,
searchPages,
verdictText,
} from "./schema-tool-strings";
@@ -12,53 +12,41 @@ afterEach(() => {
setLocale("ru");
});
const ids = (tools: { id: string }[]) => tools.map((t) => t.id);
const slugs = (pages: { slug: string }[]) => pages.map((page) => page.slug);
describe("searchTools (кросс-языковой поиск каталога)", () => {
describe("searchPages (кросс-языковой поиск каталога)", () => {
it("пустой запрос возвращает весь каталог", () => {
expect(searchTools(TOOLS, "")).toHaveLength(TOOLS.length);
expect(searchTools(TOOLS, " ")).toHaveLength(TOOLS.length);
expect(searchPages(PAGES, "")).toHaveLength(PAGES.length);
expect(searchPages(PAGES, " ")).toHaveLength(PAGES.length);
});
it("находит ru-название, даже когда локаль en", () => {
setLocale("en");
expect(ids(searchTools(TOOLS, "обрез"))).toContain("crop-png");
expect(ids(searchTools(TOOLS, "пустые поля"))).toContain(
expect(slugs(searchPages(PAGES, "обрез"))).toContain("crop-png");
expect(slugs(searchPages(PAGES, "пустые поля"))).toContain(
"trim-empty-space-png",
);
});
it("находит en-название, даже когда локаль ru", () => {
expect(ids(searchTools(TOOLS, "resize"))).toContain("resize-png");
expect(ids(searchTools(TOOLS, "round corners"))).toContain(
expect(slugs(searchPages(PAGES, "resize"))).toContain("resize-png");
expect(slugs(searchPages(PAGES, "round corners"))).toContain(
"round-corners-png",
);
});
it("находит по id", () => {
expect(ids(searchTools(TOOLS, "crop png"))).toContain("crop-png");
it("находит по slug (тексту адреса)", () => {
expect(slugs(searchPages(PAGES, "crop png"))).toContain("crop-png");
});
it("ё и е — один запрос", () => {
const byYo = searchTools(TOOLS, "чёрно");
const byYe = searchTools(TOOLS, "черно");
expect(ids(byYo)).toEqual(ids(byYe));
const byYo = searchPages(PAGES, "чёрно");
const byYe = searchPages(PAGES, "черно");
expect(slugs(byYo)).toEqual(slugs(byYe));
});
it("нет совпадения — пустой список", () => {
expect(searchTools(TOOLS, "квантовый тостер")).toEqual([]);
});
});
describe("metadata fallback", () => {
it("uses registry title and description in the base locale", () => {
setLocale("en");
for (const tool of TOOLS) {
expect(toolTitle(tool), `${tool.id}: title`).toBe(tool.title);
expect(toolDescription(tool), `${tool.id}: description`).toBe(
tool.description,
);
}
expect(searchPages(PAGES, "квантовый тостер")).toEqual([]);
});
});
+16 -19
View File
@@ -1,4 +1,4 @@
import type { ToolEntry } from "$lib/registry";
import type { Page } from "$lib/registry";
import type { Field } from "$lib/registry-schema";
import { getMergedDict } from "./locale.svelte";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
@@ -12,12 +12,12 @@ function labelOf(id: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase());
}
export function toolTitle(tool: ToolEntry): string {
return getMergedDict().tools[tool.id]?.title ?? tool.title;
export function pageTitle(page: Page): string {
return getMergedDict().tools[page.slug]?.title ?? page.title;
}
export function toolDescription(tool: ToolEntry): string {
return getMergedDict().tools[tool.id]?.description ?? tool.description;
export function pageDescription(page: Page): string {
return getMergedDict().tools[page.slug]?.description ?? page.description;
}
export function fieldLabel(field: Field<unknown>, id: string): string {
@@ -64,33 +64,30 @@ function dedupe(values: string[]): string[] {
return [...new Set(values.filter((v) => v.length > 0))];
}
export function toolSearchDoc(tool: ToolEntry): SearchDoc {
const active = getMergedDict().tools[tool.id];
export function pageSearchDoc(page: Page): SearchDoc {
const active = getMergedDict().tools[page.slug];
return {
id: tool.id,
id: page.slug,
titles: dedupe([
active?.title ?? "",
ru.tools[tool.id]?.title ?? "",
tool.title,
ru.tools[page.slug]?.title ?? "",
page.title,
]),
descriptions: dedupe([
active?.description ?? "",
ru.tools[tool.id]?.description ?? "",
tool.description,
ru.tools[page.slug]?.description ?? "",
page.description,
]),
};
}
/**
* Кросс-языковой поиск по каталогу: скор через `scoreDoc` на
* `toolSearchDoc` (заголовки/описания всех локалей). Порядок каталога
* `pageSearchDoc` (заголовки/описания всех локалей). Порядок каталога
* сохраняется, элементы без совпадения отбрасываются.
*/
export function searchTools(
tools: readonly ToolEntry[],
query: string,
): ToolEntry[] {
export function searchPages(pages: readonly Page[], query: string): Page[] {
const q = normalizeForSearch(query.trim());
if (q.length === 0) return [...tools];
return tools.filter((tool) => scoreDoc(toolSearchDoc(tool), q) !== null);
if (q.length === 0) return [...pages];
return pages.filter((page) => scoreDoc(pageSearchDoc(page), q) !== null);
}
+5 -5
View File
@@ -3,8 +3,8 @@ import { getLocale, setLocale } from "./locale.svelte";
import { LOCALE_TAGS } from "./dict";
import { normalizeForSearch } from "./matching";
import { t } from "./t";
import { getTool, TOOLS } from "../registry";
import { toolTitle } from "./schema-tool-strings";
import { getPageBySlug, PAGES } from "../registry";
import { pageTitle } from "./schema-tool-strings";
afterEach(() => {
setLocale("ru");
@@ -52,11 +52,11 @@ describe("Смоук §6 i18n (новый UI)", () => {
});
it("4. Заголовок инструмента переключается локалями (schema-tool-strings)", () => {
const tool = getTool("crop-png") ?? TOOLS[0];
const page = getPageBySlug("crop-png") ?? PAGES[0];
setLocale("ru");
expect(toolTitle(tool)).toContain("Обрезать");
expect(pageTitle(page)).toContain("Обрезать");
setLocale("en");
expect(toolTitle(tool)).toBe(tool.title);
expect(pageTitle(page)).toBe(page.title);
});
it("5. Ошибки с vars локализуются на оба языка", () => {
+16 -13
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { TOOLS, type ToolEntry } from "../registry";
import { PAGES, TOOLS, type Tool } from "../registry";
import { en } from "./en";
import { ru } from "./ru";
function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } {
function toolKeys(tool: Tool): { fields: string[]; groups: string[] } {
const fields: string[] = [];
for (const field of Object.values(tool.schema?.fields ?? {})) {
const label = (field as { spec?: { label?: string } }).spec?.label;
@@ -18,7 +18,7 @@ function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } {
type SelectOption = { value: string; label: string };
function selectOptions(
tool: ToolEntry,
tool: Tool,
): { fieldId: string; option: SelectOption }[] {
const result: { fieldId: string; option: SelectOption }[] = [];
for (const [fieldId, field] of Object.entries(tool.schema?.fields ?? {})) {
@@ -32,12 +32,12 @@ function selectOptions(
}
describe("полнота словарей для нового registry", () => {
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
for (const tool of TOOLS) {
const strings = ru.tools[tool.id];
expect(strings, `нет перевода ru для ${tool.id}`).toBeDefined();
expect(strings?.title, `${tool.id}: title`).toBeTruthy();
expect(strings?.description, `${tool.id}: description`).toBeTruthy();
it("у каждой страницы есть перевод ru с непустыми title/description", () => {
for (const page of PAGES) {
const strings = ru.tools[page.slug];
expect(strings, `нет перевода ru для ${page.slug}`).toBeDefined();
expect(strings?.title, `${page.slug}: title`).toBeTruthy();
expect(strings?.description, `${page.slug}: description`).toBeTruthy();
}
});
@@ -75,11 +75,14 @@ describe("полнота словарей для нового registry", () => {
}
});
it("в словарях нет лишних инструментов", () => {
const ids = new Set(TOOLS.map((tool) => tool.id));
it("в словарях нет ключей без записи в реестре", () => {
const known = new Set([
...TOOLS.map((tool) => tool.id),
...PAGES.map((page) => page.slug),
]);
for (const dict of [ru.tools, en.tools]) {
for (const id of Object.keys(dict)) {
expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true);
for (const key of Object.keys(dict)) {
expect(known.has(key), `лишний ключ в словаре: ${key}`).toBe(true);
}
}
});
+45 -24
View File
@@ -1,12 +1,20 @@
import { alphaEntries } from "./alpha";
import { analyzeEntries } from "./analyze";
import { colorEntries } from "./color";
import { convertEntries } from "./convert";
import { filtersEntries } from "./filters";
import { generateEntries } from "./generate";
import { geometryEntries } from "./geometry";
import { textEntries } from "./text";
import type { ToolEntry } from "./types";
import { alphaTools } from "./tools/alpha";
import { analyzeTools } from "./tools/analyze";
import { colorTools } from "./tools/color";
import { convertTools } from "./tools/convert";
import { filtersTools } from "./tools/filters";
import { generateTools } from "./tools/generate";
import { geometryTools } from "./tools/geometry";
import { textTools } from "./tools/text";
import { alphaPages } from "./pages/alpha";
import { analyzePages } from "./pages/analyze";
import { colorPages } from "./pages/color";
import { convertPages } from "./pages/convert";
import { filtersPages } from "./pages/filters";
import { generatePages } from "./pages/generate";
import { geometryPages } from "./pages/geometry";
import { textPages } from "./pages/text";
import type { Page, Tool } from "./types";
export {
genTool,
@@ -20,30 +28,43 @@ export {
export type {
FileResult,
InputMode,
Page,
PageStep,
ResultKind,
Tool,
ToolContext,
ToolEntry,
ToolImageFile,
ToolResult,
VerdictResult,
} from "./types";
export const TOOLS: ToolEntry[] = [
...geometryEntries,
...alphaEntries,
...convertEntries,
...analyzeEntries,
...filtersEntries,
...colorEntries,
...generateEntries,
...textEntries,
] as unknown as ToolEntry[];
export const TOOLS: Tool[] = [
...geometryTools,
...alphaTools,
...convertTools,
...analyzeTools,
...filtersTools,
...colorTools,
...generateTools,
...textTools,
] as unknown as Tool[];
export function getTool(id: string): ToolEntry | undefined {
export const PAGES: Page[] = [
...geometryPages,
...alphaPages,
...convertPages,
...analyzePages,
...filtersPages,
...colorPages,
...generatePages,
...textPages,
] as unknown as Page[];
export function getTool(id: string): Tool | undefined {
return TOOLS.find((tool) => tool.id === id);
}
/** Поиск по адресу страницы; `getTool` остаётся поиском по внутреннему имени. */
export function getToolBySlug(slug: string): ToolEntry | undefined {
return TOOLS.find((tool) => tool.slug === slug);
/** Единственный способ достать страницу: маршрут, крошка и имя файла идут от slug. */
export function getPageBySlug(slug: string): Page | undefined {
return PAGES.find((page) => page.slug === slug);
}
+160
View File
@@ -0,0 +1,160 @@
import type { Page } from "../types";
export const alphaPages: Page[] = [
{
slug: "add-stroke-png",
title: "Outline PNG",
description:
"Adds a colored ring outline around the opaque content with the chosen thickness.",
category: "alpha",
steps: [{ id: "add-stroke-png" }],
},
{
slug: "find-contour-png",
title: "Find contour PNG",
description:
"Leaves only a line along the boundary of opaque regions in the chosen color and thickness.",
category: "alpha",
steps: [{ id: "find-contour-png" }],
},
{
slug: "remove-color-from-png",
title: "Remove color from PNG (make transparent)",
description:
"Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.",
category: "alpha",
steps: [{ id: "remove-color-from-png" }],
},
{
slug: "circle-mask-png",
title: "Circle Mask PNG",
description:
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
category: "alpha",
steps: [{ id: "circle-mask-png" }],
},
{
slug: "square-mask-png",
title: "Square Mask PNG",
description:
"Cuts the image into a rectangle with sides as a share of the smaller side.",
category: "alpha",
steps: [{ id: "square-mask-png" }],
},
{
slug: "star-mask-png",
title: "Star Mask PNG",
description:
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
category: "alpha",
steps: [{ id: "star-mask-png" }],
},
{
slug: "wavy-mask-png",
title: "Wavy Mask PNG",
description:
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
category: "alpha",
steps: [{ id: "wavy-mask-png" }],
},
{
slug: "remove-alpha-channel-png",
title: "Remove alpha channel PNG",
description:
"Composites the image over a white background and saves without transparency.",
category: "alpha",
steps: [{ id: "remove-alpha-channel-png" }],
},
{
slug: "set-alpha-channel-png",
title: "Set alpha channel PNG",
description:
"Assigns the same opacity to all pixels; colors stay unchanged.",
category: "alpha",
steps: [{ id: "set-alpha-channel-png" }],
},
{
slug: "extract-alpha-mask-png",
title: "Extract alpha mask PNG",
description: "Turns transparency into a black-and-white opaque mask.",
category: "alpha",
steps: [{ id: "extract-alpha-mask-png" }],
},
{
slug: "round-corners-png",
title: "Round corners PNG",
description:
"Clips corners by a radius set as a percentage of half the smaller side.",
category: "alpha",
steps: [{ id: "round-corners-png" }],
},
{
slug: "invert-alpha-png",
title: "Invert alpha PNG",
description: "Opaque areas become transparent and vice versa.",
category: "alpha",
steps: [{ id: "invert-alpha-png" }],
},
{
slug: "remove-background-png",
title: "Remove background PNG (smart)",
description:
"Removes a solid background: by color with tolerance, outer regions from the edges only, or every matching pixel. Can smooth the boundary.",
category: "alpha",
steps: [{ id: "remove-background-png" }],
},
{
slug: "make-thicker-png",
title: "Thicken PNG",
description: "Expands opaque areas by the given number of pixels.",
category: "alpha",
steps: [{ id: "make-thicker-png" }],
},
{
slug: "make-thinner-png",
title: "Thin PNG",
description:
"Shrinks opaque areas — thins the strokes of text and details.",
category: "alpha",
steps: [{ id: "make-thinner-png" }],
},
{
slug: "feather-edges-png",
title: "Feather Edges PNG",
description:
"Blurs only the alpha channel: hard cutout edges become soft and gradual, colors stay untouched.",
category: "alpha",
steps: [{ id: "feather-edges-png" }],
},
{
slug: "clean-edges-png",
title: "Clean Edges PNG (defringe)",
description:
"Replaces edge-halo colors of semi-transparent pixels with the nearest fully opaque color. Alpha stays as is.",
category: "alpha",
steps: [{ id: "clean-edges-png" }],
},
{
slug: "harden-alpha-png",
title: "Harden edges PNG",
description:
"Binarizes the alpha channel by threshold: semi-transparent pixels become either fully transparent or fully opaque.",
category: "alpha",
steps: [{ id: "harden-alpha-png" }],
},
{
slug: "despeckle-alpha-png",
title: "Despeckle PNG",
description:
"Opening: removes lone semi-transparent pixels and small specks.",
category: "alpha",
steps: [{ id: "despeckle-alpha-png" }],
},
{
slug: "close-holes-png",
title: "Close holes PNG",
description: "Closing: fills lone transparent dots inside the object.",
category: "alpha",
steps: [{ id: "close-holes-png" }],
},
];
+96
View File
@@ -0,0 +1,96 @@
import type { Page } from "../types";
export const analyzePages: Page[] = [
{
slug: "extract-color-from-png",
title: "Extract Color from PNG",
description:
"Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.",
category: "analyze",
steps: [{ id: "extract-color-from-png" }],
},
{
slug: "show-transparent-png",
title: "Show Transparent Areas PNG",
description:
"Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.",
category: "analyze",
steps: [{ id: "show-transparent-png" }],
},
{
slug: "show-grayscale-pixels-png",
title: "Show Grayscale Pixels PNG",
description:
"Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.",
category: "analyze",
steps: [{ id: "show-grayscale-pixels-png" }],
},
{
slug: "show-color-pixels-png",
title: "Show Color Pixels PNG",
description:
"Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.",
category: "analyze",
steps: [{ id: "show-color-pixels-png" }],
},
{
slug: "light-pixel-mask-png",
title: "Light Pixel Mask PNG",
description: "Selects pixels brighter than the luminance threshold.",
category: "analyze",
steps: [{ id: "light-pixel-mask-png" }],
},
{
slug: "dark-pixel-mask-png",
title: "Dark Pixel Mask PNG",
description: "Selects pixels darker than the luminance threshold.",
category: "analyze",
steps: [{ id: "dark-pixel-mask-png" }],
},
{
slug: "unique-color-mask-png",
title: "Unique Color Mask PNG",
description:
"Selects colors that occur no more than the given number of times — rare and one-off pixels.",
category: "analyze",
steps: [{ id: "unique-color-mask-png" }],
},
{
slug: "verify-is-png",
title: "Verify If Image Is a PNG",
description:
"Checks the signature of pasted base64 / data-uri content and reports whether it is a real PNG.",
category: "analyze",
steps: [{ id: "verify-is-png" }],
},
{
slug: "png-is-grayscale",
title: "Check: is PNG grayscale?",
description: "Reports whether the image consists only of shades of gray.",
category: "analyze",
steps: [{ id: "png-is-grayscale" }],
},
{
slug: "png-file-size",
title: "PNG File Size",
description:
"Encodes the image as PNG and reports the resulting file size.",
category: "analyze",
steps: [{ id: "png-file-size" }],
},
{
slug: "png-is-transparent",
title: "Check: is PNG transparent?",
description:
"Reports whether the image contains transparent or semi-transparent pixels.",
category: "analyze",
steps: [{ id: "png-is-transparent" }],
},
{
slug: "png-orientation",
title: "PNG orientation",
description: "Reports whether it is portrait, landscape or square.",
category: "analyze",
steps: [{ id: "png-orientation" }],
},
];
+202
View File
@@ -0,0 +1,202 @@
import type { Page } from "../types";
export const colorPages: Page[] = [
{
slug: "two-colors-png",
title: "Two colors PNG",
description:
"Recolors the image into two chosen colors by luminance threshold.",
category: "color",
steps: [{ id: "two-colors-png" }],
},
{
slug: "gamma-png",
title: "Gamma correction PNG",
description:
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
category: "color",
steps: [{ id: "gamma-png" }],
},
{
slug: "temperature-png",
title: "Temperature PNG",
description:
"Positive values make the image warmer (more orange), negative ones cooler (more blue).",
category: "color",
steps: [{ id: "temperature-png" }],
},
{
slug: "tint-png",
title: "Tint PNG",
description:
"Multiplies color channels by the chosen tint with the given strength.",
category: "color",
steps: [{ id: "tint-png" }],
},
{
slug: "quantize-png",
title: "Quantize PNG",
description:
"Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
category: "color",
steps: [{ id: "quantize-png" }],
},
{
slug: "custom-palette-png",
title: "Custom Palette PNG",
description:
"Maps every pixel to the nearest color from your comma-separated hex list.",
category: "color",
steps: [{ id: "custom-palette-png" }],
},
{
slug: "dithering-png",
title: "Dithering PNG",
description:
"Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
category: "color",
steps: [{ id: "dithering-png" }],
},
{
slug: "grayscale-png",
title: "Grayscale PNG",
description:
"Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.",
category: "color",
steps: [{ id: "grayscale-png" }],
},
{
slug: "invert-colors-png",
title: "Invert colors PNG",
description:
"Inverts each color channel (255 − value). Alpha is unchanged.",
category: "color",
steps: [{ id: "invert-colors-png" }],
},
{
slug: "adjust-brightness-contrast-png",
title: "Brightness & contrast PNG",
description:
"Adjusts brightness and contrast in the range from −100 to +100. Zero means no change.",
category: "color",
steps: [{ id: "adjust-brightness-contrast-png" }],
},
{
slug: "change-png-opacity",
title: "Change PNG opacity",
description:
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
category: "color",
steps: [{ id: "change-png-opacity" }],
},
{
slug: "sepia-png",
title: "Sepia effect",
description: "Tints the image into the warm brown tones of classic sepia.",
category: "color",
steps: [{ id: "sepia-png" }],
},
{
slug: "change-png-hue",
title: "Change hue PNG",
description:
"Shifts the hue around the circle. Saturation and lightness are preserved.",
category: "color",
steps: [{ id: "change-png-hue" }],
},
{
slug: "extract-channel-png",
title: "Extract channel PNG",
description:
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
category: "color",
steps: [{ id: "extract-channel-png" }],
},
{
slug: "swap-channels-png",
title: "Swap channels PNG",
description:
"Swaps two color channels — a quick way to get unusual coloring.",
category: "color",
steps: [{ id: "swap-channels-png" }],
},
{
slug: "black-and-white-png",
title: "Black & white threshold PNG",
description:
"Hard binarization by luminance: every pixel becomes black or white.",
category: "color",
steps: [{ id: "black-and-white-png" }],
},
{
slug: "posterize-png",
title: "Posterize PNG",
description: "Reduces the number of levels per channel — a poster effect.",
category: "color",
steps: [{ id: "posterize-png" }],
},
{
slug: "auto-contrast-png",
title: "Auto contrast PNG",
description:
"Stretches each channel's range across the full available brightness range.",
category: "color",
steps: [{ id: "auto-contrast-png" }],
},
{
slug: "decrease-color-count-png",
title: "Decrease Color Count PNG",
description:
"Median-cut engine as a quick way to drop to 2–256 colors. Presets marked (extreme/strong/balanced/light) match the classic compression levels.",
category: "color",
steps: [{ id: "decrease-color-count-png" }],
},
{
slug: "png-to-hsl",
title: "Split PNG into HSL",
description:
"Decomposes the image into Hue, Saturation and Lightness components.",
category: "color",
steps: [{ id: "png-to-hsl" }],
},
{
slug: "png-to-hsv",
title: "Split PNG into HSV",
description:
"Decomposes the image into Hue, Saturation and Value (brightness) components.",
category: "color",
steps: [{ id: "png-to-hsv" }],
},
{
slug: "png-to-hsi",
title: "Split PNG into HSI",
description:
"Decomposes the image into Hue, Saturation and Intensity components.",
category: "color",
steps: [{ id: "png-to-hsi" }],
},
{
slug: "png-to-cmyk",
title: "Convert PNG to CMYK Colors",
description:
"Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.",
category: "color",
steps: [{ id: "png-to-cmyk" }],
},
{
slug: "png-to-ycbcr",
title: "Convert PNG to YCbCr Colors",
description:
"Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.",
category: "color",
steps: [{ id: "png-to-ycbcr" }],
},
{
slug: "png-to-lab",
title: "Convert PNG to LAB Colors",
description:
"Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.",
category: "color",
steps: [{ id: "png-to-lab" }],
},
];
+115
View File
@@ -0,0 +1,115 @@
import type { Page } from "../types";
export const convertPages: Page[] = [
{
slug: "convert-png-to-jpg",
title: "Convert PNG to JPG",
description:
"Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.",
category: "convert",
steps: [{ id: "convert-png-to-jpg" }],
},
{
slug: "convert-png-to-webp",
title: "Convert PNG to WebP",
description:
"Re-encodes the image into WebP with adjustable quality. Transparency is preserved.",
category: "convert",
steps: [{ id: "convert-png-to-webp" }],
},
{
slug: "png-to-bmp",
title: "Convert PNG to BMP",
description:
"Saves the image as 24-bit BMP without an alpha channel: transparency is replaced with a black background.",
category: "convert",
steps: [{ id: "png-to-bmp" }],
},
{
slug: "png-to-base64",
title: "PNG to Base64",
description:
"Encodes the image into a base64 string for embedding in code or styles.",
category: "convert",
steps: [{ id: "png-to-base64" }],
},
{
slug: "png-to-data-uri",
title: "PNG to Data URI",
description:
"Builds a full data-uri (data:image/png;base64,…) for embedding in HTML/CSS.",
category: "convert",
steps: [{ id: "png-to-data-uri" }],
},
{
slug: "png-to-hex",
title: "PNG to HEX pixels",
description:
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
category: "convert",
steps: [{ id: "png-to-hex" }],
},
{
slug: "png-to-bytes",
title: "PNG to Bytes",
description:
"Lists every pixel as four decimal bytes (R G B A), one image row per line.",
category: "convert",
steps: [{ id: "png-to-bytes" }],
},
{
slug: "png-to-rgb-values",
title: "PNG to RGB Values",
description:
"Lists every pixel as rgba(r, g, b, a), one image row per line.",
category: "convert",
steps: [{ id: "png-to-rgb-values" }],
},
{
slug: "base64-to-png",
title: "Base64 to PNG",
description:
"Decodes a base64 string or data-uri back into an image. Paste the string on the left.",
category: "convert",
steps: [{ id: "base64-to-png" }],
},
{
slug: "data-uri-to-png",
title: "Data URI to PNG",
description: "Decodes data:image/…;base64,… back into an image file.",
category: "convert",
steps: [{ id: "data-uri-to-png" }],
},
{
slug: "hex-to-png",
title: "HEX pixels to PNG",
description:
"Assembles an image from rrggbbaa hex values (space separated). Set the width — the height is computed automatically.",
category: "convert",
steps: [{ id: "hex-to-png" }],
},
{
slug: "bytes-to-png",
title: "Bytes to PNG",
description:
"Assembles an image from decimal RGBA byte numbers (any separators). Set the width — height is computed automatically.",
category: "convert",
steps: [{ id: "bytes-to-png" }],
},
{
slug: "rgb-values-to-png",
title: "RGB Values to PNG",
description:
"Assembles an image from rgba(r, g, b, a) numbers. Set the width — height is computed automatically.",
category: "convert",
steps: [{ id: "rgb-values-to-png" }],
},
{
slug: "svg-to-png",
title: "SVG to PNG",
description:
"Decodes SVG markup into a raster image. Paste the SVG code on the left.",
category: "convert",
steps: [{ id: "svg-to-png" }],
},
];
+68
View File
@@ -0,0 +1,68 @@
import type { Page } from "../types";
export const filtersPages: Page[] = [
{
slug: "blur-png",
title: "Blur PNG",
description:
"Gaussian blur: three passes of separable box blur — fast at any radius. Transparent edges do not darken.",
category: "filters",
steps: [{ id: "blur-png" }],
},
{
slug: "sharpen-png",
title: "Sharpen PNG",
description:
"Emphasizes edges with a sharpening kernel; strength sets the blend with the original. 0% means no change.",
category: "filters",
steps: [{ id: "sharpen-png" }],
},
{
slug: "silhouette-png",
title: "Silhouette PNG",
description:
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
category: "filters",
steps: [{ id: "silhouette-png" }],
},
{
slug: "vignette-png",
title: "Vignette PNG",
description:
"Smoothly darkens the edges of the image, leaving the center untouched.",
category: "filters",
steps: [{ id: "vignette-png" }],
},
{
slug: "pixelate-png",
title: "Pixelate PNG",
description:
"Averages every blockSize×blockSize area into one color — classic mosaic.",
category: "filters",
steps: [{ id: "pixelate-png" }],
},
{
slug: "randomize-pixels-png",
title: "Randomize Pixels PNG",
description:
"Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
category: "filters",
steps: [{ id: "randomize-pixels-png" }],
},
{
slug: "add-noise-png",
title: "Add Noise to PNG",
description:
"Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.",
category: "filters",
steps: [{ id: "add-noise-png" }],
},
{
slug: "jpeg-artifacts-png",
title: "JPEG artifacts",
description:
"Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
category: "filters",
steps: [{ id: "jpeg-artifacts-png" }],
},
];
+167
View File
@@ -0,0 +1,167 @@
import type { Page } from "../types";
export const generatePages: Page[] = [
{
slug: "create-empty-png",
title: "Create empty PNG",
description:
"Creates a blank canvas of the chosen dimensions, either transparent or filled with a solid color.",
category: "generate",
steps: [{ id: "create-empty-png" }],
},
{
slug: "single-color-png",
title: "Create solid color PNG",
description: "Generates a rectangle of the given size and color.",
category: "generate",
steps: [{ id: "single-color-png" }],
},
{
slug: "random-noise-png",
title: "Create random noise PNG",
description:
"Generates an image with random pixels. The seed fixes the result: one seed — one image.",
category: "generate",
steps: [{ id: "random-noise-png" }],
},
{
slug: "linear-gradient-png",
title: "Create gradient PNG",
description:
"Generates a smooth transition between two colors along a chosen angle.",
category: "generate",
steps: [{ id: "linear-gradient-png" }],
},
{
slug: "color-spectrum-png",
title: "Color Spectrum PNG",
description:
"Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.",
category: "generate",
steps: [{ id: "color-spectrum-png" }],
},
{
slug: "random-colors-png",
title: "Random Color Blocks PNG",
description:
"Fills the canvas with random vivid color blocks. Deterministic by seed.",
category: "generate",
steps: [{ id: "random-colors-png" }],
},
{
slug: "draw-grid-png",
title: "Draw Grid PNG",
description:
"Draws a grid with custom columns, rows and line width on a transparent or white background.",
category: "generate",
steps: [{ id: "draw-grid-png" }],
},
{
slug: "placeholder-png",
title: "Create Placeholder PNG",
description:
"Generates a placeholder rectangle with its dimensions printed in the center.",
category: "generate",
steps: [{ id: "placeholder-png" }],
},
{
slug: "blend-two-png",
title: "Blend Two Colors PNG",
description: "A continuous horizontal gradient between two colors.",
category: "generate",
steps: [{ id: "blend-two-png" }],
},
{
slug: "step-colors-png",
title: "Color Steps PNG",
description: "A discrete set of evenly spaced steps between two colors.",
category: "generate",
steps: [{ id: "step-colors-png" }],
},
{
slug: "emoji-to-png",
title: "Emoji to PNG",
description:
"Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.",
category: "generate",
steps: [{ id: "emoji-to-png" }],
},
{
slug: "color-wheel-png",
title: "Color Wheel PNG",
description:
"Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.",
category: "generate",
steps: [{ id: "color-wheel-png" }],
},
{
slug: "complementary-png",
title: "Complementary Palette PNG",
description:
"Two opposite colors on the color wheel — the base and its complement.",
category: "generate",
steps: [{ id: "complementary-png" }],
},
{
slug: "triadic-png",
title: "Triadic Palette PNG",
description: "Three colors evenly spaced 120° apart on the color wheel.",
category: "generate",
steps: [{ id: "triadic-png" }],
},
{
slug: "tetradic-png",
title: "Tetradic Palette PNG",
description:
"Four colors in two complementary pairs, 90° apart on the wheel.",
category: "generate",
steps: [{ id: "tetradic-png" }],
},
{
slug: "analogous-png",
title: "Analogous Palette PNG",
description:
"Neighboring hues around the base color — calm, related color scheme.",
category: "generate",
steps: [{ id: "analogous-png" }],
},
{
slug: "monochromatic-png",
title: "Monochromatic Palette PNG",
description:
"Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.",
category: "generate",
steps: [{ id: "monochromatic-png" }],
},
{
slug: "shades-png",
title: "Shade Ramp PNG",
description: "A ramp of the base color getting darker step by step.",
category: "generate",
steps: [{ id: "shades-png" }],
},
{
slug: "mix-colors-png",
title: "Mix Colors PNG",
description:
"Averages the selected colors into one swatch. Colors become one uniform fill.",
category: "generate",
steps: [{ id: "mix-colors-png" }],
},
{
slug: "sort-colors-png",
title: "Sort Colors PNG",
description:
"Renders the chosen colors as swatches sorted by hue, brightness or saturation.",
category: "generate",
steps: [{ id: "sort-colors-png" }],
},
{
slug: "text-to-png",
title: "Text to PNG",
description:
"Creates a PNG image from text: the canvas is sized to fit the label plus padding.",
category: "generate",
steps: [{ id: "text-to-png" }],
},
];
+152
View File
@@ -0,0 +1,152 @@
import type { Page } from "../types";
export const geometryPages: Page[] = [
{
slug: "add-border-png",
title: "Add border to PNG",
description:
"Draws a colored frame of the chosen thickness around the image.",
category: "geometry",
steps: [{ id: "add-border-png" }],
},
{
slug: "fit-on-background-png",
title: "Fit PNG onto background",
description:
"Places the image centered on a canvas of the given size with a transparent or colored background.",
category: "geometry",
steps: [{ id: "fit-on-background-png" }],
},
{
slug: "change-canvas-size-png",
title: "Change Canvas Size PNG",
description:
"Sets the exact canvas size: overflow is cropped, missing space is filled with transparency. Anchor picks which part of the image stays.",
category: "geometry",
steps: [{ id: "change-canvas-size-png" }],
},
{
slug: "resize-png",
title: "Resize PNG",
description:
"Scales the image with bilinear interpolation. With aspect kept, one side defines the scale; if both are set, the image fits inside them.",
category: "geometry",
steps: [{ id: "resize-png" }],
},
{
slug: "crop-png",
title: "Crop PNG",
description:
"Cuts out a rectangular area. Coordinates and sizes may go beyond the image — the area is clipped to the intersection.",
category: "geometry",
steps: [{ id: "crop-png" }],
},
{
slug: "rotate-png",
title: "Rotate PNG",
description: "Rotates by 90°, 180° or 270° clockwise without quality loss.",
category: "geometry",
steps: [{ id: "rotate-png" }],
},
{
slug: "flip-png",
title: "Flip PNG",
description: "Mirrors horizontally or vertically without quality loss.",
category: "geometry",
steps: [{ id: "flip-png" }],
},
{
slug: "add-padding-png",
title: "Add padding to PNG",
description:
"Expands the canvas on all sides by the chosen number of pixels.",
category: "geometry",
steps: [{ id: "add-padding-png" }],
},
{
slug: "tile-png",
title: "Tile PNG",
description: "Repeats the image in a grid of the chosen columns and rows.",
category: "geometry",
steps: [{ id: "tile-png" }],
},
{
slug: "split-into-parts-png",
title: "Split PNG into parts",
description:
"Divides the image into a grid of equal-sized parts. The canvas is padded with transparency to keep every part the same size.",
category: "geometry",
steps: [{ id: "split-into-parts-png" }],
},
{
slug: "center-by-alpha-png",
title: "Center PNG by content",
description:
"Finds the opaque part of the image and centers it on the original canvas.",
category: "geometry",
steps: [{ id: "center-by-alpha-png" }],
},
{
slug: "skew-png",
title: "Skew PNG",
description:
"Shifts content horizontally and vertically — a perspective effect.",
category: "geometry",
steps: [{ id: "skew-png" }],
},
{
slug: "rotate-free-png",
title: "Rotate by custom angle",
description:
"Rotation by any angle. The canvas grows to fit the new bounds; corners stay transparent.",
category: "geometry",
steps: [{ id: "rotate-free-png" }],
},
{
slug: "zoom-png",
title: "Zoom PNG",
description:
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
category: "geometry",
steps: [{ id: "zoom-png" }],
},
{
slug: "trim-empty-space-png",
title: "Trim Empty Space PNG",
description:
"Crops transparent borders around the content. Pixels with alpha above the threshold count as content.",
category: "geometry",
steps: [{ id: "trim-empty-space-png" }],
},
{
slug: "change-aspect-ratio-png",
title: "Change Aspect Ratio PNG",
description:
"Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.",
category: "geometry",
steps: [{ id: "change-aspect-ratio-png" }],
},
{
slug: "swap-orientation-png",
title: "Swap Orientation PNG",
description:
"Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.",
category: "geometry",
steps: [{ id: "swap-orientation-png" }],
},
{
slug: "symmetric-copy-png",
title: "Symmetric Copy PNG",
description:
"Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.",
category: "geometry",
steps: [{ id: "symmetric-copy-png" }],
},
{
slug: "shift-png",
title: "Shift PNG",
description: "Moves content by the given X and Y offset.",
category: "geometry",
steps: [{ id: "shift-png" }],
},
];
+28
View File
@@ -0,0 +1,28 @@
import type { Page } from "../types";
export const textPages: Page[] = [
{
slug: "add-text-png",
title: "Add text to PNG",
description:
"Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.",
category: "text",
steps: [{ id: "add-text-png" }],
},
{
slug: "date-stamp-png",
title: "Date stamp PNG",
description:
"Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.",
category: "text",
steps: [{ id: "date-stamp-png" }],
},
{
slug: "watermark-tile-png",
title: "Watermark Tile PNG",
description:
"Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.",
category: "text",
steps: [{ id: "watermark-tile-png" }],
},
];
+36 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { TOOLS } from ".";
import { PAGES, TOOLS } from ".";
import { PREVIEW_GROUPS } from "../catalog";
import type { PixelImage } from "../core/types";
import type { OutputMime } from "../core/io";
@@ -14,13 +14,13 @@ function asImage(result: ToolResult): PixelImage {
}
describe("registry-new (переведённые инструменты)", () => {
it("id уникальны", () => {
it("id инструментов уникальны", () => {
const ids = TOOLS.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("slug уникальны", () => {
const slugs = TOOLS.map((t) => t.slug);
it("slug страниц уникальны", () => {
const slugs = PAGES.map((page) => page.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
@@ -30,14 +30,43 @@ describe("registry-new (переведённые инструменты)", () =>
const shape = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
for (const tool of TOOLS) {
expect(shape.test(tool.id), `id: ${tool.id}`).toBe(true);
expect(shape.test(tool.slug), `slug: ${tool.slug}`).toBe(true);
}
for (const page of PAGES) {
expect(shape.test(page.slug), `slug: ${page.slug}`).toBe(true);
}
});
it("каждый шаг страницы ссылается на существующий инструмент", () => {
const ids = new Set(TOOLS.map((tool) => tool.id));
for (const page of PAGES) {
expect(page.steps.length, `${page.slug}: шагов`).toBeGreaterThan(0);
for (const step of page.steps) {
expect(ids.has(step.id), `${page.slug} → ${step.id}`).toBe(true);
}
}
});
it("каждый инструмент используется хотя бы одной страницей", () => {
const used = new Set(PAGES.flatMap((page) => page.steps.map((s) => s.id)));
const orphans = TOOLS.map((tool) => tool.id).filter((id) => !used.has(id));
expect(orphans, `инструменты без страниц:\n${orphans.join("\n")}`).toEqual(
[],
);
});
it("guard: у страницы ровно один шаг", () => {
// Модель допускает цепочку, но `SchemaToolView` исполняет первый шаг и
// форма параметров одна на страницу. Исполнение цепочек и общая форма
// из полей двух инструментов — фаза 11 `docs/roadmap.md`.
for (const page of PAGES) {
expect(page.steps.length, `${page.slug}: шагов`).toBe(1);
}
});
it("PREVIEW_GROUPS строится без ошибок", () => {
expect(PREVIEW_GROUPS.length).toBeGreaterThan(0);
expect(PREVIEW_GROUPS.reduce((n, g) => n + g.tools.length, 0)).toBe(
TOOLS.length,
expect(PREVIEW_GROUPS.reduce((n, g) => n + g.pages.length, 0)).toBe(
PAGES.length,
);
});
+16 -16
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { TOOLS } from ".";
import { PAGES } from ".";
const MAP_PATH = fileURLToPath(
new URL("../../../../docs/tools-map.md", import.meta.url),
@@ -10,7 +10,7 @@ const MAP_PATH = fileURLToPath(
const SECTION_START = /^## 1\.\s/;
const SECTION_END = /^## 2\.\s/;
// Все id в реестре содержат дефис, поэтому формат строгий: это отсекает слова
// Все slug страниц содержат дефис, поэтому формат строгий: это отсекает слова
// описания вроде `median-cut` из «colors (k, median-cut)».
const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
@@ -18,9 +18,9 @@ type Mention = { id: string; line: number };
/**
* Буллиты раздела «1. Реализовано» вместе с номером первой строки.
* Перенесённые строки приклеиваются к буллету: иначе id, оказавшийся на
* следующей строке, молча выпадал бы из разбора и инструмент считался бы
* неописанным.
* Перенесённые строки приклеиваются к буллету: иначе slug, оказавшийся на
* следующей строке, молча выпадал бы из разбора и страница считалась бы
* неописанной.
*/
function implementedBullets(
markdown: string,
@@ -56,7 +56,7 @@ function implementedBullets(
}
/**
* Всё до первого тире `—` — id через `/` или `,`; после тире свободное
* Всё до первого тире `—` — slug через `/` или `,`; после тире свободное
* описание параметров, оно не разбирается. Формат закреплён в самой карте.
*/
function mentionedIds(markdown: string): Mention[] {
@@ -71,39 +71,39 @@ function mentionedIds(markdown: string): Mention[] {
return found;
}
describe("docs/tools-map.md ↔ реестр", () => {
describe("docs/tools-map.md ↔ страницы реестра", () => {
const markdown = readFileSync(MAP_PATH, "utf8");
const mentioned = mentionedIds(markdown);
const registryIds = new Set(TOOLS.map((t) => t.id));
const registrySlugs = new Set(PAGES.map((page) => page.slug));
it("разбирает непустой список id (парсер не сломался молча)", () => {
it("разбирает непустой список slug (парсер не сломался молча)", () => {
expect(implementedBullets(markdown).length).toBeGreaterThan(0);
expect(new Set(mentioned.map((m) => m.id)).size).toBeGreaterThan(50);
});
it("в карте нет id, которых нет в реестре", () => {
const bogus = mentioned.filter((m) => !registryIds.has(m.id));
it("в карте нет slug, которых нет в реестре", () => {
const bogus = mentioned.filter((m) => !registrySlugs.has(m.id));
expect(
bogus,
bogus
.map(
(m) =>
`${m.id} (docs/tools-map.md:${m.line}) — такого инструмента нет в web/src/lib/registry/`,
`${m.id} (docs/tools-map.md:${m.line}) — такой страницы нет в web/src/lib/registry/pages/`,
)
.join("\n"),
).toEqual([]);
});
it("каждый инструмент реестра описан в карте", () => {
it("каждая страница реестра описана в карте", () => {
const mentionedSet = new Set(mentioned.map((m) => m.id));
const missing = [...registryIds].filter((id) => !mentionedSet.has(id));
const missing = [...registrySlugs].filter((id) => !mentionedSet.has(id));
expect(
missing,
`не описаны в разделе «Реализовано»:\n${missing.join("\n")}`,
).toEqual([]);
});
it("id не продублированы между буллетами", () => {
it("slug не продублированы между буллетами", () => {
const seen = new Map<string, number>();
const dupes: string[] = [];
for (const { id, line } of mentioned) {
@@ -114,6 +114,6 @@ describe("docs/tools-map.md ↔ реестр", () => {
seen.set(id, line);
}
}
expect(dupes, `повторяющиеся id:\n${dupes.join("\n")}`).toEqual([]);
expect(dupes, `повторяющиеся slug:\n${dupes.join("\n")}`).toEqual([]);
});
});
@@ -6,8 +6,8 @@ import {
removeColorToAlpha,
roundCorners,
setAlphaChannel,
} from "../core/alpha";
import { removeBackground } from "../core/background";
} from "../../core/alpha";
import { removeBackground } from "../../core/background";
import {
closingImage,
contourImage,
@@ -15,18 +15,18 @@ import {
erodeImage,
openingImage,
strokeImage,
} from "../core/morphology";
import { defringe, featherAlpha } from "../core/pixel-fx";
} from "../../core/morphology";
import { defringe, featherAlpha } from "../../core/pixel-fx";
import {
boxTest,
circleTest,
renderShape,
starTest,
wavyTest,
} from "../core/shapes";
import type { Offset } from "../registry-schema";
import { field, toolSchema } from "../registry-schema";
import { imgTool, type ToolEntry } from "./types";
} from "../../core/shapes";
import type { Offset } from "../../registry-schema";
import { field, toolSchema } from "../../registry-schema";
import { imgTool, type Tool } from "../types";
interface AddStrokeParams {
color: string;
@@ -44,13 +44,8 @@ export const addStrokeSchema = toolSchema<AddStrokeParams>({
}),
});
const addStroke: ToolEntry<AddStrokeParams> = {
const addStroke: Tool<AddStrokeParams> = {
id: "add-stroke-png",
slug: "add-stroke-png",
title: "Outline PNG",
description:
"Adds a colored ring outline around the opaque content with the chosen thickness.",
category: "alpha",
schema: addStrokeSchema,
input: "image",
run: imgTool((img, p) => strokeImage(img, p.thickness, p.color)),
@@ -72,13 +67,8 @@ export const findContourSchema = toolSchema<FindContourParams>({
}),
});
const findContour: ToolEntry<FindContourParams> = {
const findContour: Tool<FindContourParams> = {
id: "find-contour-png",
slug: "find-contour-png",
title: "Find contour PNG",
description:
"Leaves only a line along the boundary of opaque regions in the chosen color and thickness.",
category: "alpha",
schema: findContourSchema,
input: "image",
run: imgTool((img, p) => contourImage(img, p.thickness, p.color)),
@@ -100,13 +90,8 @@ export const removeColorSchema = toolSchema<RemoveColorParams>({
}),
});
const removeColor: ToolEntry<RemoveColorParams> = {
const removeColor: Tool<RemoveColorParams> = {
id: "remove-color-from-png",
slug: "remove-color-from-png",
title: "Remove color from PNG (make transparent)",
description:
"Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.",
category: "alpha",
schema: removeColorSchema,
input: "image",
run: imgTool((img, p) => removeColorToAlpha(img, p.targetColor, p.tolerance)),
@@ -144,13 +129,8 @@ const circleMaskSchema = toolSchema<CircleMaskParams>(
},
);
const circleMask: ToolEntry<CircleMaskParams> = {
const circleMask: Tool<CircleMaskParams> = {
id: "circle-mask-png",
slug: "circle-mask-png",
title: "Circle Mask PNG",
description:
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
category: "alpha",
schema: circleMaskSchema,
input: "image",
run: imgTool((img, p) =>
@@ -207,13 +187,8 @@ const squareMaskSchema = toolSchema<SquareMaskParams>(
},
);
const squareMask: ToolEntry<SquareMaskParams> = {
const squareMask: Tool<SquareMaskParams> = {
id: "square-mask-png",
slug: "square-mask-png",
title: "Square Mask PNG",
description:
"Cuts the image into a rectangle with sides as a share of the smaller side.",
category: "alpha",
schema: squareMaskSchema,
input: "image",
run: imgTool((img, p) =>
@@ -286,13 +261,8 @@ const starMaskSchema = toolSchema<StarMaskParams>(
},
);
const starMask: ToolEntry<StarMaskParams> = {
const starMask: Tool<StarMaskParams> = {
id: "star-mask-png",
slug: "star-mask-png",
title: "Star Mask PNG",
description:
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
category: "alpha",
schema: starMaskSchema,
input: "image",
run: imgTool((img, p) =>
@@ -365,13 +335,8 @@ const wavyMaskSchema = toolSchema<WavyMaskParams>(
},
);
const wavyMask: ToolEntry<WavyMaskParams> = {
const wavyMask: Tool<WavyMaskParams> = {
id: "wavy-mask-png",
slug: "wavy-mask-png",
title: "Wavy Mask PNG",
description:
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
category: "alpha",
schema: wavyMaskSchema,
input: "image",
run: imgTool((img, p) =>
@@ -388,13 +353,8 @@ interface EmptyParams {}
export const removeAlphaChannelSchema = toolSchema<EmptyParams>({});
const removeAlphaChannel: ToolEntry<EmptyParams> = {
const removeAlphaChannel: Tool<EmptyParams> = {
id: "remove-alpha-channel-png",
slug: "remove-alpha-channel-png",
title: "Remove alpha channel PNG",
description:
"Composites the image over a white background and saves without transparency.",
category: "alpha",
schema: removeAlphaChannelSchema,
input: "image",
run: imgTool((img) => flattenOntoColor(img, "#ffffff")),
@@ -414,12 +374,8 @@ export const setAlphaChannelSchema = toolSchema<SetAlphaChannelParams>({
}),
});
const setAlphaChannelTool: ToolEntry<SetAlphaChannelParams> = {
const setAlphaChannelTool: Tool<SetAlphaChannelParams> = {
id: "set-alpha-channel-png",
slug: "set-alpha-channel-png",
title: "Set alpha channel PNG",
description: "Assigns the same opacity to all pixels; colors stay unchanged.",
category: "alpha",
schema: setAlphaChannelSchema,
input: "image",
run: imgTool((img, p) => setAlphaChannel(img, p.percent)),
@@ -427,12 +383,8 @@ const setAlphaChannelTool: ToolEntry<SetAlphaChannelParams> = {
export const extractAlphaMaskSchema = toolSchema<EmptyParams>({});
const extractAlphaMaskTool: ToolEntry<EmptyParams> = {
const extractAlphaMaskTool: Tool<EmptyParams> = {
id: "extract-alpha-mask-png",
slug: "extract-alpha-mask-png",
title: "Extract alpha mask PNG",
description: "Turns transparency into a black-and-white opaque mask.",
category: "alpha",
schema: extractAlphaMaskSchema,
input: "image",
run: imgTool((img) => extractAlphaMask(img)),
@@ -452,13 +404,8 @@ export const roundCornersSchema = toolSchema<RoundCornersParams>({
}),
});
const roundCornersTool: ToolEntry<RoundCornersParams> = {
const roundCornersTool: Tool<RoundCornersParams> = {
id: "round-corners-png",
slug: "round-corners-png",
title: "Round corners PNG",
description:
"Clips corners by a radius set as a percentage of half the smaller side.",
category: "alpha",
schema: roundCornersSchema,
input: "image",
run: imgTool((img, p) => roundCorners(img, p.radius)),
@@ -466,12 +413,8 @@ const roundCornersTool: ToolEntry<RoundCornersParams> = {
export const invertAlphaSchema = toolSchema<EmptyParams>({});
const invertAlphaTool: ToolEntry<EmptyParams> = {
const invertAlphaTool: Tool<EmptyParams> = {
id: "invert-alpha-png",
slug: "invert-alpha-png",
title: "Invert alpha PNG",
description: "Opaque areas become transparent and vice versa.",
category: "alpha",
schema: invertAlphaSchema,
input: "image",
run: imgTool((img) => invertAlpha(img)),
@@ -517,13 +460,8 @@ export const removeBackgroundSchema = toolSchema<RemoveBackgroundParams>(
},
);
const removeBackgroundTool: ToolEntry<RemoveBackgroundParams> = {
const removeBackgroundTool: Tool<RemoveBackgroundParams> = {
id: "remove-background-png",
slug: "remove-background-png",
title: "Remove background PNG (smart)",
description:
"Removes a solid background: by color with tolerance, outer regions from the edges only, or every matching pixel. Can smooth the boundary.",
category: "alpha",
schema: removeBackgroundSchema,
input: "image",
run: imgTool((img, p) =>
@@ -550,12 +488,8 @@ export const makeThickerSchema = toolSchema<MakeThickerParams>({
}),
});
const makeThickerTool: ToolEntry<MakeThickerParams> = {
const makeThickerTool: Tool<MakeThickerParams> = {
id: "make-thicker-png",
slug: "make-thicker-png",
title: "Thicken PNG",
description: "Expands opaque areas by the given number of pixels.",
category: "alpha",
schema: makeThickerSchema,
input: "image",
run: imgTool((img, p) => dilateImage(img, p.radius)),
@@ -575,12 +509,8 @@ export const makeThinnerSchema = toolSchema<MakeThinnerParams>({
}),
});
const makeThinnerTool: ToolEntry<MakeThinnerParams> = {
const makeThinnerTool: Tool<MakeThinnerParams> = {
id: "make-thinner-png",
slug: "make-thinner-png",
title: "Thin PNG",
description: "Shrinks opaque areas — thins the strokes of text and details.",
category: "alpha",
schema: makeThinnerSchema,
input: "image",
run: imgTool((img, p) => erodeImage(img, p.radius)),
@@ -600,13 +530,8 @@ export const featherEdgesSchema = toolSchema<FeatherEdgesParams>({
}),
});
const featherEdgesTool: ToolEntry<FeatherEdgesParams> = {
const featherEdgesTool: Tool<FeatherEdgesParams> = {
id: "feather-edges-png",
slug: "feather-edges-png",
title: "Feather Edges PNG",
description:
"Blurs only the alpha channel: hard cutout edges become soft and gradual, colors stay untouched.",
category: "alpha",
schema: featherEdgesSchema,
input: "image",
run: imgTool((img, p) => featherAlpha(img, p.radius)),
@@ -626,13 +551,8 @@ export const cleanEdgesSchema = toolSchema<CleanEdgesParams>({
}),
});
const cleanEdgesTool: ToolEntry<CleanEdgesParams> = {
const cleanEdgesTool: Tool<CleanEdgesParams> = {
id: "clean-edges-png",
slug: "clean-edges-png",
title: "Clean Edges PNG (defringe)",
description:
"Replaces edge-halo colors of semi-transparent pixels with the nearest fully opaque color. Alpha stays as is.",
category: "alpha",
schema: cleanEdgesSchema,
input: "image",
run: imgTool((img, p) => defringe(img, p.radius)),
@@ -652,13 +572,8 @@ export const hardenAlphaSchema = toolSchema<HardenAlphaParams>({
}),
});
const hardenAlphaTool: ToolEntry<HardenAlphaParams> = {
const hardenAlphaTool: Tool<HardenAlphaParams> = {
id: "harden-alpha-png",
slug: "harden-alpha-png",
title: "Harden edges PNG",
description:
"Binarizes the alpha channel by threshold: semi-transparent pixels become either fully transparent or fully opaque.",
category: "alpha",
schema: hardenAlphaSchema,
input: "image",
run: imgTool((img, p) => hardenAlpha(img, p.threshold)),
@@ -678,13 +593,8 @@ export const despeckleAlphaSchema = toolSchema<DespeckleAlphaParams>({
}),
});
const despeckleAlphaTool: ToolEntry<DespeckleAlphaParams> = {
const despeckleAlphaTool: Tool<DespeckleAlphaParams> = {
id: "despeckle-alpha-png",
slug: "despeckle-alpha-png",
title: "Despeckle PNG",
description:
"Opening: removes lone semi-transparent pixels and small specks.",
category: "alpha",
schema: despeckleAlphaSchema,
input: "image",
run: imgTool((img, p) => openingImage(img, p.radius)),
@@ -704,18 +614,14 @@ export const closeHolesSchema = toolSchema<CloseHolesParams>({
}),
});
const closeHolesTool: ToolEntry<CloseHolesParams> = {
const closeHolesTool: Tool<CloseHolesParams> = {
id: "close-holes-png",
slug: "close-holes-png",
title: "Close holes PNG",
description: "Closing: fills lone transparent dots inside the object.",
category: "alpha",
schema: closeHolesSchema,
input: "image",
run: imgTool((img, p) => closingImage(img, p.radius)),
};
export const alphaEntries = [
export const alphaTools = [
addStroke,
findContour,
removeColor,
@@ -1,15 +1,19 @@
import { hasTransparency, isGrayscale, orientationOf } from "../core/analyze";
import { encode } from "../core/io";
import {
hasTransparency,
isGrayscale,
orientationOf,
} from "../../core/analyze";
import { encode } from "../../core/io";
import {
extractByColor,
isGrayscaleish,
luma01,
rarityPredicate,
renderPredicateMask,
} from "../core/masks";
import { base64ToBytes, looksLikePng, stripDataUri } from "../core/textio";
import { field, toolSchema } from "../registry-schema";
import { imgTool, textGen, type ToolEntry } from "./types";
} from "../../core/masks";
import { base64ToBytes, looksLikePng, stripDataUri } from "../../core/textio";
import { field, toolSchema } from "../../registry-schema";
import { imgTool, textGen, type Tool } from "../types";
interface ExtractColorParams {
color: string;
@@ -27,13 +31,8 @@ export const extractColorSchema = toolSchema<ExtractColorParams>({
}),
});
const extractColor: ToolEntry<ExtractColorParams> = {
const extractColor: Tool<ExtractColorParams> = {
id: "extract-color-from-png",
slug: "extract-color-from-png",
title: "Extract Color from PNG",
description:
"Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.",
category: "analyze",
schema: extractColorSchema,
input: "image",
run: imgTool((img, p) => extractByColor(img, p.color, p.tolerance)),
@@ -88,13 +87,8 @@ export const showTransparentSchema = toolSchema<MaskParams>({
}),
});
const showTransparent: ToolEntry<MaskParams> = {
const showTransparent: Tool<MaskParams> = {
id: "show-transparent-png",
slug: "show-transparent-png",
title: "Show Transparent Areas PNG",
description:
"Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.",
category: "analyze",
schema: showTransparentSchema,
input: "image",
run: imgTool((img, p) => renderMask(img, p, (_r, _g, _b, a) => a < 255)),
@@ -115,13 +109,8 @@ export const showGrayscalePixelsSchema = toolSchema<GrayscalePixelsParams>({
}),
});
const showGrayscalePixels: ToolEntry<GrayscalePixelsParams> = {
const showGrayscalePixels: Tool<GrayscalePixelsParams> = {
id: "show-grayscale-pixels-png",
slug: "show-grayscale-pixels-png",
title: "Show Grayscale Pixels PNG",
description:
"Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.",
category: "analyze",
schema: showGrayscalePixelsSchema,
input: "image",
run: imgTool((img, p) =>
@@ -144,13 +133,8 @@ export const showColorPixelsSchema = toolSchema<ColorPixelsParams>({
}),
});
const showColorPixels: ToolEntry<ColorPixelsParams> = {
const showColorPixels: Tool<ColorPixelsParams> = {
id: "show-color-pixels-png",
slug: "show-color-pixels-png",
title: "Show Color Pixels PNG",
description:
"Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.",
category: "analyze",
schema: showColorPixelsSchema,
input: "image",
run: imgTool((img, p) =>
@@ -173,12 +157,8 @@ export const lightPixelMaskSchema = toolSchema<LightPixelParams>({
}),
});
const lightPixelMask: ToolEntry<LightPixelParams> = {
const lightPixelMask: Tool<LightPixelParams> = {
id: "light-pixel-mask-png",
slug: "light-pixel-mask-png",
title: "Light Pixel Mask PNG",
description: "Selects pixels brighter than the luminance threshold.",
category: "analyze",
schema: lightPixelMaskSchema,
input: "image",
run: imgTool((img, p) =>
@@ -201,12 +181,8 @@ export const darkPixelMaskSchema = toolSchema<DarkPixelParams>({
}),
});
const darkPixelMask: ToolEntry<DarkPixelParams> = {
const darkPixelMask: Tool<DarkPixelParams> = {
id: "dark-pixel-mask-png",
slug: "dark-pixel-mask-png",
title: "Dark Pixel Mask PNG",
description: "Selects pixels darker than the luminance threshold.",
category: "analyze",
schema: darkPixelMaskSchema,
input: "image",
run: imgTool((img, p) =>
@@ -229,13 +205,8 @@ export const uniqueColorMaskSchema = toolSchema<UniqueColorParams>({
}),
});
const uniqueColorMask: ToolEntry<UniqueColorParams> = {
const uniqueColorMask: Tool<UniqueColorParams> = {
id: "unique-color-mask-png",
slug: "unique-color-mask-png",
title: "Unique Color Mask PNG",
description:
"Selects colors that occur no more than the given number of times — rare and one-off pixels.",
category: "analyze",
schema: uniqueColorMaskSchema,
input: "image",
run: imgTool((img, p) => renderMask(img, p, rarityPredicate(img, p.rarity))),
@@ -245,13 +216,8 @@ interface NoParams {}
const emptySchema = toolSchema<NoParams>({});
const verifyIsPng: ToolEntry<NoParams> = {
const verifyIsPng: Tool<NoParams> = {
id: "verify-is-png",
slug: "verify-is-png",
title: "Verify If Image Is a PNG",
description:
"Checks the signature of pasted base64 / data-uri content and reports whether it is a real PNG.",
category: "analyze",
schema: emptySchema,
input: "text",
result: "verdict",
@@ -260,24 +226,16 @@ const verifyIsPng: ToolEntry<NoParams> = {
),
};
const pngIsGrayscale: ToolEntry<NoParams> = {
const pngIsGrayscale: Tool<NoParams> = {
id: "png-is-grayscale",
slug: "png-is-grayscale",
title: "Check: is PNG grayscale?",
description: "Reports whether the image consists only of shades of gray.",
category: "analyze",
schema: emptySchema,
input: "image",
result: "verdict",
run: imgTool((img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo")),
};
const pngFileSize: ToolEntry<NoParams> = {
const pngFileSize: Tool<NoParams> = {
id: "png-file-size",
slug: "png-file-size",
title: "PNG File Size",
description: "Encodes the image as PNG and reports the resulting file size.",
category: "analyze",
schema: emptySchema,
domOnly: true,
input: "image",
@@ -290,13 +248,8 @@ const pngFileSize: ToolEntry<NoParams> = {
}),
};
const pngIsTransparent: ToolEntry<NoParams> = {
const pngIsTransparent: Tool<NoParams> = {
id: "png-is-transparent",
slug: "png-is-transparent",
title: "Check: is PNG transparent?",
description:
"Reports whether the image contains transparent or semi-transparent pixels.",
category: "analyze",
schema: emptySchema,
input: "image",
result: "verdict",
@@ -305,12 +258,8 @@ const pngIsTransparent: ToolEntry<NoParams> = {
),
};
const pngOrientation: ToolEntry<NoParams> = {
const pngOrientation: Tool<NoParams> = {
id: "png-orientation",
slug: "png-orientation",
title: "PNG orientation",
description: "Reports whether it is portrait, landscape or square.",
category: "analyze",
schema: emptySchema,
input: "image",
result: "verdict",
@@ -326,7 +275,7 @@ const pngOrientation: ToolEntry<NoParams> = {
}),
};
export const analyzeEntries = [
export const analyzeTools = [
extractColor,
showTransparent,
showGrayscalePixels,
@@ -16,12 +16,12 @@ import {
twoColors,
type ChannelSwapPair,
type RgbChannel,
} from "../core/color";
import { renderSpace, SPACES, type SpaceId } from "../core/channels";
import { parseHexList } from "../core/palette";
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize";
import { field, toolSchema, type ColorPair } from "../registry-schema";
import { imgTool, type ToolEntry } from "./types";
} from "../../core/color";
import { renderSpace, SPACES, type SpaceId } from "../../core/channels";
import { parseHexList } from "../../core/palette";
import { ditherImage, mapToNearest, quantizeImage } from "../../core/quantize";
import { field, toolSchema, type ColorPair } from "../../registry-schema";
import { imgTool, type Tool } from "../types";
interface TwoColorsParams {
pair: ColorPair;
@@ -43,13 +43,8 @@ export const twoColorsSchema = toolSchema<TwoColorsParams>({
}),
});
const twoColorsTool: ToolEntry<TwoColorsParams> = {
const twoColorsTool: Tool<TwoColorsParams> = {
id: "two-colors-png",
slug: "two-colors-png",
title: "Two colors PNG",
description:
"Recolors the image into two chosen colors by luminance threshold.",
category: "color",
schema: twoColorsSchema,
input: "image",
run: imgTool((img, p) => twoColors(img, p.pair.from, p.pair.to, p.threshold)),
@@ -69,13 +64,8 @@ export const gammaSchema = toolSchema<GammaParams>({
}),
});
const gammaTool: ToolEntry<GammaParams> = {
const gammaTool: Tool<GammaParams> = {
id: "gamma-png",
slug: "gamma-png",
title: "Gamma correction PNG",
description:
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
category: "color",
schema: gammaSchema,
input: "image",
run: imgTool((img, p) => gammaCorrection(img, p.value)),
@@ -95,13 +85,8 @@ export const temperatureSchema = toolSchema<TemperatureParams>({
}),
});
const temperatureTool: ToolEntry<TemperatureParams> = {
const temperatureTool: Tool<TemperatureParams> = {
id: "temperature-png",
slug: "temperature-png",
title: "Temperature PNG",
description:
"Positive values make the image warmer (more orange), negative ones cooler (more blue).",
category: "color",
schema: temperatureSchema,
input: "image",
run: imgTool((img, p) => temperature(img, p.percent)),
@@ -123,13 +108,8 @@ export const tintSchema = toolSchema<TintParams>({
}),
});
const tintTool: ToolEntry<TintParams> = {
const tintTool: Tool<TintParams> = {
id: "tint-png",
slug: "tint-png",
title: "Tint PNG",
description:
"Multiplies color channels by the chosen tint with the given strength.",
category: "color",
schema: tintSchema,
input: "image",
run: imgTool((img, p) => tint(img, p.color, p.strength)),
@@ -149,13 +129,8 @@ export const quantizeSchema = toolSchema<QuantizeParams>({
}),
});
const quantizeTool: ToolEntry<QuantizeParams> = {
const quantizeTool: Tool<QuantizeParams> = {
id: "quantize-png",
slug: "quantize-png",
title: "Quantize PNG",
description:
"Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
category: "color",
schema: quantizeSchema,
input: "image",
run: imgTool((img, p) => quantizeImage(img, p.colors).image),
@@ -169,13 +144,8 @@ export const customPaletteSchema = toolSchema<CustomPaletteParams>({
colors: field.text({ label: "fields.hexList", default: "#000000,#ffffff" }),
});
const customPalette: ToolEntry<CustomPaletteParams> = {
const customPalette: Tool<CustomPaletteParams> = {
id: "custom-palette-png",
slug: "custom-palette-png",
title: "Custom Palette PNG",
description:
"Maps every pixel to the nearest color from your comma-separated hex list.",
category: "color",
schema: customPaletteSchema,
input: "image",
run: imgTool((img, p) => mapToNearest(img, parseHexList(p.colors))),
@@ -204,13 +174,8 @@ export const ditheringSchema = toolSchema<DitheringParams>({
}),
});
const ditheringTool: ToolEntry<DitheringParams> = {
const ditheringTool: Tool<DitheringParams> = {
id: "dithering-png",
slug: "dithering-png",
title: "Dithering PNG",
description:
"Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
category: "color",
schema: ditheringSchema,
input: "image",
run: imgTool((img, p) => ditherImage(img, p.colors, p.pattern)),
@@ -220,13 +185,8 @@ interface EmptyParams {}
export const grayscaleSchema = toolSchema<EmptyParams>({});
const grayscaleTool: ToolEntry<EmptyParams> = {
const grayscaleTool: Tool<EmptyParams> = {
id: "grayscale-png",
slug: "grayscale-png",
title: "Grayscale PNG",
description:
"Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.",
category: "color",
schema: grayscaleSchema,
input: "image",
run: imgTool((img) => grayscale(img)),
@@ -234,12 +194,8 @@ const grayscaleTool: ToolEntry<EmptyParams> = {
export const invertColorsSchema = toolSchema<EmptyParams>({});
const invertColorsTool: ToolEntry<EmptyParams> = {
const invertColorsTool: Tool<EmptyParams> = {
id: "invert-colors-png",
slug: "invert-colors-png",
title: "Invert colors PNG",
description: "Inverts each color channel (255 − value). Alpha is unchanged.",
category: "color",
schema: invertColorsSchema,
input: "image",
run: imgTool((img) => invert(img)),
@@ -276,13 +232,8 @@ export const brightnessContrastSchema = toolSchema<BrightnessContrastParams>(
},
);
const brightnessContrastTool: ToolEntry<BrightnessContrastParams> = {
const brightnessContrastTool: Tool<BrightnessContrastParams> = {
id: "adjust-brightness-contrast-png",
slug: "adjust-brightness-contrast-png",
title: "Brightness & contrast PNG",
description:
"Adjusts brightness and contrast in the range from −100 to +100. Zero means no change.",
category: "color",
schema: brightnessContrastSchema,
input: "image",
run: imgTool((img, p) => brightnessContrast(img, p.brightness, p.contrast)),
@@ -302,13 +253,8 @@ export const opacitySchema = toolSchema<OpacityParams>({
}),
});
const opacityTool: ToolEntry<OpacityParams> = {
const opacityTool: Tool<OpacityParams> = {
id: "change-png-opacity",
slug: "change-png-opacity",
title: "Change PNG opacity",
description:
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
category: "color",
schema: opacitySchema,
input: "image",
run: imgTool((img, p) => setOpacity(img, p.percent)),
@@ -316,12 +262,8 @@ const opacityTool: ToolEntry<OpacityParams> = {
export const sepiaSchema = toolSchema<EmptyParams>({});
const sepiaTool: ToolEntry<EmptyParams> = {
const sepiaTool: Tool<EmptyParams> = {
id: "sepia-png",
slug: "sepia-png",
title: "Sepia effect",
description: "Tints the image into the warm brown tones of classic sepia.",
category: "color",
schema: sepiaSchema,
input: "image",
run: imgTool((img) => sepia(img)),
@@ -341,13 +283,8 @@ export const hueShiftSchema = toolSchema<HueShiftParams>({
}),
});
const hueShiftTool: ToolEntry<HueShiftParams> = {
const hueShiftTool: Tool<HueShiftParams> = {
id: "change-png-hue",
slug: "change-png-hue",
title: "Change hue PNG",
description:
"Shifts the hue around the circle. Saturation and lightness are preserved.",
category: "color",
schema: hueShiftSchema,
input: "image",
run: imgTool((img, p) => changeHue(img, p.degrees)),
@@ -369,13 +306,8 @@ export const extractChannelSchema = toolSchema<ExtractChannelParams>({
}),
});
const extractChannelTool: ToolEntry<ExtractChannelParams> = {
const extractChannelTool: Tool<ExtractChannelParams> = {
id: "extract-channel-png",
slug: "extract-channel-png",
title: "Extract channel PNG",
description:
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
category: "color",
schema: extractChannelSchema,
input: "image",
run: imgTool((img, p) => extractChannel(img, p.channel)),
@@ -397,13 +329,8 @@ export const swapChannelsSchema = toolSchema<SwapChannelsParams>({
}),
});
const swapChannelsTool: ToolEntry<SwapChannelsParams> = {
const swapChannelsTool: Tool<SwapChannelsParams> = {
id: "swap-channels-png",
slug: "swap-channels-png",
title: "Swap channels PNG",
description:
"Swaps two color channels — a quick way to get unusual coloring.",
category: "color",
schema: swapChannelsSchema,
input: "image",
run: imgTool((img, p) => swapChannels(img, p.pair)),
@@ -423,13 +350,8 @@ export const blackAndWhiteSchema = toolSchema<BlackAndWhiteParams>({
}),
});
const blackAndWhiteTool: ToolEntry<BlackAndWhiteParams> = {
const blackAndWhiteTool: Tool<BlackAndWhiteParams> = {
id: "black-and-white-png",
slug: "black-and-white-png",
title: "Black & white threshold PNG",
description:
"Hard binarization by luminance: every pixel becomes black or white.",
category: "color",
schema: blackAndWhiteSchema,
input: "image",
run: imgTool((img, p) => thresholdBlackWhite(img, p.threshold)),
@@ -449,12 +371,8 @@ export const posterizeSchema = toolSchema<PosterizeParams>({
}),
});
const posterizeTool: ToolEntry<PosterizeParams> = {
const posterizeTool: Tool<PosterizeParams> = {
id: "posterize-png",
slug: "posterize-png",
title: "Posterize PNG",
description: "Reduces the number of levels per channel — a poster effect.",
category: "color",
schema: posterizeSchema,
input: "image",
run: imgTool((img, p) => posterize(img, p.levels)),
@@ -462,13 +380,8 @@ const posterizeTool: ToolEntry<PosterizeParams> = {
export const autoContrastSchema = toolSchema<EmptyParams>({});
const autoContrastTool: ToolEntry<EmptyParams> = {
const autoContrastTool: Tool<EmptyParams> = {
id: "auto-contrast-png",
slug: "auto-contrast-png",
title: "Auto contrast PNG",
description:
"Stretches each channel's range across the full available brightness range.",
category: "color",
schema: autoContrastSchema,
input: "image",
run: imgTool((img) => autoContrast(img)),
@@ -499,13 +412,8 @@ export const decreaseColorCountSchema = toolSchema<DecreaseColorCountParams>({
}),
});
const decreaseColorCountTool: ToolEntry<DecreaseColorCountParams> = {
const decreaseColorCountTool: Tool<DecreaseColorCountParams> = {
id: "decrease-color-count-png",
slug: "decrease-color-count-png",
title: "Decrease Color Count PNG",
description:
"Median-cut engine as a quick way to drop to 2–256 colors. Presets marked (extreme/strong/balanced/light) match the classic compression levels.",
category: "color",
schema: decreaseColorCountSchema,
input: "image",
run: imgTool((img, p) => quantizeImage(img, Number(p.maxColors)).image),
@@ -516,60 +424,15 @@ interface ChannelParams {
display: "gray" | "color";
}
type SpaceEntry = {
id: SpaceId;
title: string;
description: string;
};
// Пространства, для которых есть инструмент и страница; тексты страницы живут
// в `registry/pages/color.ts`, поэтому здесь только идентификаторы.
const CHANNEL_SPACES: SpaceId[] = ["hsl", "hsv", "hsi", "cmyk", "ycbcr", "lab"];
const CHANNEL_SPACES: SpaceEntry[] = [
{
id: "hsl",
title: "Split PNG into HSL",
description:
"Decomposes the image into Hue, Saturation and Lightness components.",
},
{
id: "hsv",
title: "Split PNG into HSV",
description:
"Decomposes the image into Hue, Saturation and Value (brightness) components.",
},
{
id: "hsi",
title: "Split PNG into HSI",
description:
"Decomposes the image into Hue, Saturation and Intensity components.",
},
{
id: "cmyk",
title: "Convert PNG to CMYK Colors",
description:
"Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.",
},
{
id: "ycbcr",
title: "Convert PNG to YCbCr Colors",
description:
"Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.",
},
{
id: "lab",
title: "Convert PNG to LAB Colors",
description:
"Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.",
},
];
function channelEntries(): ToolEntry<ChannelParams>[] {
function channelEntries(): Tool<ChannelParams>[] {
return CHANNEL_SPACES.map((space) => {
const components = SPACES[space.id].components;
const components = SPACES[space].components;
return {
id: `png-to-${space.id}`,
slug: `png-to-${space.id}`,
title: space.title,
description: space.description,
category: "color",
id: `png-to-${space}`,
schema: toolSchema<ChannelParams>({
component: field.select({
label: "fields.component",
@@ -589,14 +452,12 @@ function channelEntries(): ToolEntry<ChannelParams>[] {
}),
}),
input: "image",
run: imgTool((img, p) =>
renderSpace(img, space.id, p.component, p.display),
),
} satisfies ToolEntry<ChannelParams>;
run: imgTool((img, p) => renderSpace(img, space, p.component, p.display)),
} satisfies Tool<ChannelParams>;
});
}
export const colorEntries = [
export const colorTools = [
twoColorsTool,
gammaTool,
temperatureTool,
@@ -1,6 +1,6 @@
import { flattenOntoColor } from "../core/alpha";
import { decodeBytes, decodeSvgText, toBase64, toDataUrl } from "../core/io";
import { hexToPixels, pixelsToHex } from "../core/text";
import { flattenOntoColor } from "../../core/alpha";
import { decodeBytes, decodeSvgText, toBase64, toDataUrl } from "../../core/io";
import { hexToPixels, pixelsToHex } from "../../core/text";
import {
base64ToBytes,
bytesToImage,
@@ -8,10 +8,10 @@ import {
imageToRgbValues,
rgbValuesToImage,
stripDataUri,
} from "../core/textio";
import { clonePixelImage } from "../core/types";
import { field, toolSchema } from "../registry-schema";
import { imgTool, textGen, type ToolEntry } from "./types";
} from "../../core/textio";
import { clonePixelImage } from "../../core/types";
import { field, toolSchema } from "../../registry-schema";
import { imgTool, textGen, type Tool } from "../types";
const widthProps = (defaultValue: number) =>
field.number({
@@ -38,13 +38,8 @@ export const convertToJpgSchema = toolSchema<ConvertToJpgParams>({
}),
});
const convertToJpg: ToolEntry<ConvertToJpgParams> = {
const convertToJpg: Tool<ConvertToJpgParams> = {
id: "convert-png-to-jpg",
slug: "convert-png-to-jpg",
title: "Convert PNG to JPG",
description:
"Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.",
category: "convert",
schema: convertToJpgSchema,
input: "image",
run: imgTool((img, p) => flattenOntoColor(img, p.background)),
@@ -65,13 +60,8 @@ export const convertToWebpSchema = toolSchema<ConvertToWebpParams>({
}),
});
const convertToWebp: ToolEntry<ConvertToWebpParams> = {
const convertToWebp: Tool<ConvertToWebpParams> = {
id: "convert-png-to-webp",
slug: "convert-png-to-webp",
title: "Convert PNG to WebP",
description:
"Re-encodes the image into WebP with adjustable quality. Transparency is preserved.",
category: "convert",
schema: convertToWebpSchema,
input: "image",
run: imgTool((img) => clonePixelImage(img)),
@@ -82,13 +72,8 @@ interface ConvertToBmpParams {}
export const convertToBmpSchema = toolSchema<ConvertToBmpParams>({});
const convertToBmp: ToolEntry<ConvertToBmpParams> = {
const convertToBmp: Tool<ConvertToBmpParams> = {
id: "png-to-bmp",
slug: "png-to-bmp",
title: "Convert PNG to BMP",
description:
"Saves the image as 24-bit BMP without an alpha channel: transparency is replaced with a black background.",
category: "convert",
schema: convertToBmpSchema,
input: "image",
run: imgTool((img) => flattenOntoColor(img, "#000000")),
@@ -99,13 +84,8 @@ interface NoParams {}
const emptySchema = toolSchema<NoParams>({});
const pngToBase64: ToolEntry<NoParams> = {
const pngToBase64: Tool<NoParams> = {
id: "png-to-base64",
slug: "png-to-base64",
title: "PNG to Base64",
description:
"Encodes the image into a base64 string for embedding in code or styles.",
category: "convert",
schema: emptySchema,
input: "image",
result: "text",
@@ -113,13 +93,8 @@ const pngToBase64: ToolEntry<NoParams> = {
run: imgTool((img) => toBase64(img)),
};
const pngToDataUri: ToolEntry<NoParams> = {
const pngToDataUri: Tool<NoParams> = {
id: "png-to-data-uri",
slug: "png-to-data-uri",
title: "PNG to Data URI",
description:
"Builds a full data-uri (data:image/png;base64,…) for embedding in HTML/CSS.",
category: "convert",
schema: emptySchema,
input: "image",
result: "text",
@@ -127,63 +102,40 @@ const pngToDataUri: ToolEntry<NoParams> = {
run: imgTool((img) => toDataUrl(img)),
};
const pngToHex: ToolEntry<NoParams> = {
const pngToHex: Tool<NoParams> = {
id: "png-to-hex",
slug: "png-to-hex",
title: "PNG to HEX pixels",
description:
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
category: "convert",
schema: emptySchema,
input: "image",
result: "text",
run: imgTool((img) => pixelsToHex(img)),
};
const pngToBytes: ToolEntry<NoParams> = {
const pngToBytes: Tool<NoParams> = {
id: "png-to-bytes",
slug: "png-to-bytes",
title: "PNG to Bytes",
description:
"Lists every pixel as four decimal bytes (R G B A), one image row per line.",
category: "convert",
schema: emptySchema,
input: "image",
result: "text",
run: imgTool((img) => imageToByteRows(img)),
};
const pngToRgbValues: ToolEntry<NoParams> = {
const pngToRgbValues: Tool<NoParams> = {
id: "png-to-rgb-values",
slug: "png-to-rgb-values",
title: "PNG to RGB Values",
description: "Lists every pixel as rgba(r, g, b, a), one image row per line.",
category: "convert",
schema: emptySchema,
input: "image",
result: "text",
run: imgTool((img) => imageToRgbValues(img)),
};
const base64ToPng: ToolEntry<NoParams> = {
const base64ToPng: Tool<NoParams> = {
id: "base64-to-png",
slug: "base64-to-png",
title: "Base64 to PNG",
description:
"Decodes a base64 string or data-uri back into an image. Paste the string on the left.",
category: "convert",
schema: emptySchema,
input: "text",
domOnly: true,
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
};
const dataUriToPng: ToolEntry<NoParams> = {
const dataUriToPng: Tool<NoParams> = {
id: "data-uri-to-png",
slug: "data-uri-to-png",
title: "Data URI to PNG",
description: "Decodes data:image/…;base64,… back into an image file.",
category: "convert",
schema: emptySchema,
input: "text",
domOnly: true,
@@ -198,13 +150,8 @@ export const hexToPngSchema = toolSchema<HexToPngParams>({
width: widthProps(1),
});
const hexToPng: ToolEntry<HexToPngParams> = {
const hexToPng: Tool<HexToPngParams> = {
id: "hex-to-png",
slug: "hex-to-png",
title: "HEX pixels to PNG",
description:
"Assembles an image from rrggbbaa hex values (space separated). Set the width — the height is computed automatically.",
category: "convert",
schema: hexToPngSchema,
input: "text",
run: textGen((text, p) => hexToPixels(text, Math.trunc(p.width))),
@@ -218,13 +165,8 @@ export const bytesToPngSchema = toolSchema<BytesToPngParams>({
width: widthProps(32),
});
const bytesToPng: ToolEntry<BytesToPngParams> = {
const bytesToPng: Tool<BytesToPngParams> = {
id: "bytes-to-png",
slug: "bytes-to-png",
title: "Bytes to PNG",
description:
"Assembles an image from decimal RGBA byte numbers (any separators). Set the width — height is computed automatically.",
category: "convert",
schema: bytesToPngSchema,
input: "text",
run: textGen((text, p) => bytesToImage(text, Math.trunc(p.width))),
@@ -238,13 +180,8 @@ export const rgbValuesToPngSchema = toolSchema<RgbValuesToPngParams>({
width: widthProps(32),
});
const rgbValuesToPng: ToolEntry<RgbValuesToPngParams> = {
const rgbValuesToPng: Tool<RgbValuesToPngParams> = {
id: "rgb-values-to-png",
slug: "rgb-values-to-png",
title: "RGB Values to PNG",
description:
"Assembles an image from rgba(r, g, b, a) numbers. Set the width — height is computed automatically.",
category: "convert",
schema: rgbValuesToPngSchema,
input: "text",
run: textGen((text, p) => rgbValuesToImage(text, Math.trunc(p.width))),
@@ -258,20 +195,15 @@ export const svgToPngSchema = toolSchema<SvgToPngParams>({
width: widthProps(512),
});
const svgToPng: ToolEntry<SvgToPngParams> = {
const svgToPng: Tool<SvgToPngParams> = {
id: "svg-to-png",
slug: "svg-to-png",
title: "SVG to PNG",
description:
"Decodes SVG markup into a raster image. Paste the SVG code on the left.",
category: "convert",
schema: svgToPngSchema,
input: "text",
domOnly: true,
run: textGen((text, p) => decodeSvgText(text, Math.trunc(p.width))),
};
export const convertEntries = [
export const convertTools = [
convertToJpg,
convertToWebp,
convertToBmp,
@@ -1,14 +1,14 @@
import { gaussianBlur, sharpen as sharpenImage } from "../core/convolution";
import { vignette } from "../core/effects";
import { jpegRoundtrip } from "../core/io";
import { gaussianBlur, sharpen as sharpenImage } from "../../core/convolution";
import { vignette } from "../../core/effects";
import { jpegRoundtrip } from "../../core/io";
import {
addNoise,
pixelate,
shuffleBlocks,
silhouette,
} from "../core/pixel-fx";
import { field, toolSchema } from "../registry-schema";
import { imgTool, type ToolEntry } from "./types";
} from "../../core/pixel-fx";
import { field, toolSchema } from "../../registry-schema";
import { imgTool, type Tool } from "../types";
interface BlurParams {
radius: number;
@@ -24,13 +24,8 @@ export const blurSchema = toolSchema<BlurParams>({
}),
});
const blurTool: ToolEntry<BlurParams> = {
const blurTool: Tool<BlurParams> = {
id: "blur-png",
slug: "blur-png",
title: "Blur PNG",
description:
"Gaussian blur: three passes of separable box blur — fast at any radius. Transparent edges do not darken.",
category: "filters",
schema: blurSchema,
input: "image",
run: imgTool((img, p) => gaussianBlur(img, p.radius)),
@@ -50,13 +45,8 @@ export const sharpenSchema = toolSchema<SharpenParams>({
}),
});
const sharpenTool: ToolEntry<SharpenParams> = {
const sharpenTool: Tool<SharpenParams> = {
id: "sharpen-png",
slug: "sharpen-png",
title: "Sharpen PNG",
description:
"Emphasizes edges with a sharpening kernel; strength sets the blend with the original. 0% means no change.",
category: "filters",
schema: sharpenSchema,
input: "image",
run: imgTool((img, p) => sharpenImage(img, p.strength)),
@@ -78,13 +68,8 @@ export const silhouetteSchema = toolSchema<SilhouetteParams>({
}),
});
const silhouetteTool: ToolEntry<SilhouetteParams> = {
const silhouetteTool: Tool<SilhouetteParams> = {
id: "silhouette-png",
slug: "silhouette-png",
title: "Silhouette PNG",
description:
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
category: "filters",
schema: silhouetteSchema,
input: "image",
run: imgTool((img, p) => silhouette(img, p.color, p.threshold * 2.55)),
@@ -104,13 +89,8 @@ export const vignetteSchema = toolSchema<VignetteParams>({
}),
});
const vignetteTool: ToolEntry<VignetteParams> = {
const vignetteTool: Tool<VignetteParams> = {
id: "vignette-png",
slug: "vignette-png",
title: "Vignette PNG",
description:
"Smoothly darkens the edges of the image, leaving the center untouched.",
category: "filters",
schema: vignetteSchema,
input: "image",
run: imgTool((img, p) => vignette(img, p.strength)),
@@ -130,13 +110,8 @@ export const pixelateSchema = toolSchema<PixelateParams>({
}),
});
const pixelateTool: ToolEntry<PixelateParams> = {
const pixelateTool: Tool<PixelateParams> = {
id: "pixelate-png",
slug: "pixelate-png",
title: "Pixelate PNG",
description:
"Averages every blockSize×blockSize area into one color — classic mosaic.",
category: "filters",
schema: pixelateSchema,
input: "image",
run: imgTool((img, p) => pixelate(img, p.blockSize)),
@@ -173,13 +148,8 @@ export const randomizePixelsSchema = toolSchema<RandomizePixelsParams>(
},
);
const randomizePixels: ToolEntry<RandomizePixelsParams> = {
const randomizePixels: Tool<RandomizePixelsParams> = {
id: "randomize-pixels-png",
slug: "randomize-pixels-png",
title: "Randomize Pixels PNG",
description:
"Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
category: "filters",
schema: randomizePixelsSchema,
input: "image",
run: imgTool((img, p) => shuffleBlocks(img, p.blockSize, p.seed)),
@@ -226,13 +196,8 @@ export const addNoiseSchema = toolSchema<AddNoiseParams>(
},
);
const addNoiseTool: ToolEntry<AddNoiseParams> = {
const addNoiseTool: Tool<AddNoiseParams> = {
id: "add-noise-png",
slug: "add-noise-png",
title: "Add Noise to PNG",
description:
"Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.",
category: "filters",
schema: addNoiseSchema,
input: "image",
run: imgTool((img, p) => addNoise(img, p.amount, p.mode, p.seed)),
@@ -252,20 +217,15 @@ export const jpegArtifactsSchema = toolSchema<JpegArtifactsParams>({
}),
});
const jpegArtifacts: ToolEntry<JpegArtifactsParams> = {
const jpegArtifacts: Tool<JpegArtifactsParams> = {
id: "jpeg-artifacts-png",
slug: "jpeg-artifacts-png",
title: "JPEG artifacts",
description:
"Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
category: "filters",
schema: jpegArtifactsSchema,
input: "image",
domOnly: true,
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
};
export const filtersEntries = [
export const filtersTools = [
vignetteTool,
pixelateTool,
randomizePixels,
@@ -1,7 +1,11 @@
import { renderEmoji, renderTextToImage } from "../core/domText";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
import { noiseImage, solidImage } from "../core/generate";
import { changeCanvasSize } from "../core/geometry";
import { renderEmoji, renderTextToImage } from "../../core/domText";
import {
colorSpectrum,
drawGrid,
randomColorBlocks,
} from "../../core/gen-tools";
import { noiseImage, solidImage } from "../../core/generate";
import { changeCanvasSize } from "../../core/geometry";
import {
analogousSet,
complementarySet,
@@ -16,8 +20,8 @@ import {
stepColors,
tetradicSet,
triadicSet,
} from "../core/palette";
import type { PixelImage } from "../core/types";
} from "../../core/palette";
import type { PixelImage } from "../../core/types";
import {
field,
toolSchema,
@@ -25,8 +29,8 @@ import {
type Dimension,
type FontStyle,
type Gradient,
} from "../registry-schema";
import { genTool, type ToolEntry } from "./types";
} from "../../registry-schema";
import { genTool, type Tool } from "../types";
function rgba(hex: string): [number, number, number, number] {
const { r, g, b } = hexToRgb(hex);
@@ -100,13 +104,8 @@ export const createEmptySchema = toolSchema<CreateEmptyParams>(
},
);
const createEmpty: ToolEntry<CreateEmptyParams> = {
const createEmpty: Tool<CreateEmptyParams> = {
id: "create-empty-png",
slug: "create-empty-png",
title: "Create empty PNG",
description:
"Creates a blank canvas of the chosen dimensions, either transparent or filled with a solid color.",
category: "generate",
schema: createEmptySchema,
input: "none",
run: genTool((p) => {
@@ -135,12 +134,8 @@ export const singleColorSchema = toolSchema<SingleColorParams>({
color: field.color({ label: "fields.color", default: "#ff0000" }),
});
const singleColor: ToolEntry<SingleColorParams> = {
const singleColor: Tool<SingleColorParams> = {
id: "single-color-png",
slug: "single-color-png",
title: "Create solid color PNG",
description: "Generates a rectangle of the given size and color.",
category: "generate",
schema: singleColorSchema,
input: "none",
run: genTool((p) => {
@@ -172,13 +167,8 @@ export const randomNoiseSchema = toolSchema<RandomNoiseParams>({
}),
});
const randomNoise: ToolEntry<RandomNoiseParams> = {
const randomNoise: Tool<RandomNoiseParams> = {
id: "random-noise-png",
slug: "random-noise-png",
title: "Create random noise PNG",
description:
"Generates an image with random pixels. The seed fixes the result: one seed — one image.",
category: "generate",
schema: randomNoiseSchema,
input: "none",
run: genTool((p) => {
@@ -219,13 +209,8 @@ export const linearGradientSchema = toolSchema<LinearGradientParams>(
},
);
const linearGradient: ToolEntry<LinearGradientParams> = {
const linearGradient: Tool<LinearGradientParams> = {
id: "linear-gradient-png",
slug: "linear-gradient-png",
title: "Create gradient PNG",
description:
"Generates a smooth transition between two colors along a chosen angle.",
category: "generate",
schema: linearGradientSchema,
input: "none",
run: genTool((p) => {
@@ -293,13 +278,8 @@ export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>(
},
);
const colorSpectrumTool: ToolEntry<ColorSpectrumParams> = {
const colorSpectrumTool: Tool<ColorSpectrumParams> = {
id: "color-spectrum-png",
slug: "color-spectrum-png",
title: "Color Spectrum PNG",
description:
"Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.",
category: "generate",
schema: colorSpectrumSchema,
input: "none",
run: genTool((p) => {
@@ -349,13 +329,8 @@ export const randomColorsSchema = toolSchema<RandomColorsParams>(
},
);
const randomColors: ToolEntry<RandomColorsParams> = {
const randomColors: Tool<RandomColorsParams> = {
id: "random-colors-png",
slug: "random-colors-png",
title: "Random Color Blocks PNG",
description:
"Fills the canvas with random vivid color blocks. Deterministic by seed.",
category: "generate",
schema: randomColorsSchema,
input: "none",
run: genTool((p) => {
@@ -424,13 +399,8 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
},
);
const drawGridTool: ToolEntry<DrawGridParams> = {
const drawGridTool: Tool<DrawGridParams> = {
id: "draw-grid-png",
slug: "draw-grid-png",
title: "Draw Grid PNG",
description:
"Draws a grid with custom columns, rows and line width on a transparent or white background.",
category: "generate",
schema: drawGridSchema,
input: "none",
run: genTool((p) => {
@@ -486,13 +456,8 @@ export const placeholderSchema = toolSchema<PlaceholderParams>(
},
);
const placeholder: ToolEntry<PlaceholderParams> = {
const placeholder: Tool<PlaceholderParams> = {
id: "placeholder-png",
slug: "placeholder-png",
title: "Create Placeholder PNG",
description:
"Generates a placeholder rectangle with its dimensions printed in the center.",
category: "generate",
schema: placeholderSchema,
input: "none",
domOnly: true,
@@ -537,12 +502,8 @@ export const blendTwoSchema = toolSchema<BlendTwoParams>({
}),
});
const blendTwo: ToolEntry<BlendTwoParams> = {
const blendTwo: Tool<BlendTwoParams> = {
id: "blend-two-png",
slug: "blend-two-png",
title: "Blend Two Colors PNG",
description: "A continuous horizontal gradient between two colors.",
category: "generate",
schema: blendTwoSchema,
input: "none",
run: genTool((p) => renderBlend(p.pair.from, p.pair.to, p.width)),
@@ -599,12 +560,8 @@ export const stepColorsSchema = toolSchema<StepColorsParams>(
},
);
const stepColorsTool: ToolEntry<StepColorsParams> = {
const stepColorsTool: Tool<StepColorsParams> = {
id: "step-colors-png",
slug: "step-colors-png",
title: "Color Steps PNG",
description: "A discrete set of evenly spaced steps between two colors.",
category: "generate",
schema: stepColorsSchema,
input: "none",
run: genTool((p) =>
@@ -632,13 +589,8 @@ export const emojiToPngSchema = toolSchema<EmojiToPngParams>({
}),
});
const emojiToPng: ToolEntry<EmojiToPngParams> = {
const emojiToPng: Tool<EmojiToPngParams> = {
id: "emoji-to-png",
slug: "emoji-to-png",
title: "Emoji to PNG",
description:
"Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.",
category: "generate",
domOnly: true,
schema: emojiToPngSchema,
input: "none",
@@ -667,13 +619,8 @@ export const colorWheelSchema = toolSchema<ColorWheelParams>({
}),
});
const colorWheelTool: ToolEntry<ColorWheelParams> = {
const colorWheelTool: Tool<ColorWheelParams> = {
id: "color-wheel-png",
slug: "color-wheel-png",
title: "Color Wheel PNG",
description:
"Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.",
category: "generate",
schema: colorWheelSchema,
input: "none",
run: genTool((p) => renderWheel(p.size, p.lightness)),
@@ -717,13 +664,8 @@ const paletteLayoutGroup = {
},
};
const complementaryTool: ToolEntry<PaletteBaseParams> = {
const complementaryTool: Tool<PaletteBaseParams> = {
id: "complementary-png",
slug: "complementary-png",
title: "Complementary Palette PNG",
description:
"Two opposite colors on the color wheel — the base and its complement.",
category: "generate",
schema: toolSchema<PaletteBaseParams>(
{ ...paletteBaseSchema },
{ ...paletteLayoutGroup },
@@ -734,12 +676,8 @@ const complementaryTool: ToolEntry<PaletteBaseParams> = {
),
};
const triadicTool: ToolEntry<PaletteBaseParams> = {
const triadicTool: Tool<PaletteBaseParams> = {
id: "triadic-png",
slug: "triadic-png",
title: "Triadic Palette PNG",
description: "Three colors evenly spaced 120° apart on the color wheel.",
category: "generate",
schema: toolSchema<PaletteBaseParams>(
{
...paletteBaseSchema,
@@ -753,13 +691,8 @@ const triadicTool: ToolEntry<PaletteBaseParams> = {
),
};
const tetradicTool: ToolEntry<PaletteBaseParams> = {
const tetradicTool: Tool<PaletteBaseParams> = {
id: "tetradic-png",
slug: "tetradic-png",
title: "Tetradic Palette PNG",
description:
"Four colors in two complementary pairs, 90° apart on the wheel.",
category: "generate",
schema: toolSchema<PaletteBaseParams>(
{
...paletteBaseSchema,
@@ -778,13 +711,8 @@ interface AnalogousParams extends PaletteBaseParams {
count: number;
}
const analogousTool: ToolEntry<AnalogousParams> = {
const analogousTool: Tool<AnalogousParams> = {
id: "analogous-png",
slug: "analogous-png",
title: "Analogous Palette PNG",
description:
"Neighboring hues around the base color — calm, related color scheme.",
category: "generate",
schema: toolSchema<AnalogousParams>(
{
...paletteBaseSchema,
@@ -821,13 +749,8 @@ interface MonochromaticParams extends PaletteBaseParams {
range: number;
}
const monochromaticTool: ToolEntry<MonochromaticParams> = {
const monochromaticTool: Tool<MonochromaticParams> = {
id: "monochromatic-png",
slug: "monochromatic-png",
title: "Monochromatic Palette PNG",
description:
"Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.",
category: "generate",
schema: toolSchema<MonochromaticParams>(
{
...paletteBaseSchema,
@@ -864,12 +787,8 @@ interface ShadesParams extends PaletteBaseParams {
depth: number;
}
const shadesTool: ToolEntry<ShadesParams> = {
const shadesTool: Tool<ShadesParams> = {
id: "shades-png",
slug: "shades-png",
title: "Shade Ramp PNG",
description: "A ramp of the base color getting darker step by step.",
category: "generate",
schema: toolSchema<ShadesParams>(
{
...paletteBaseSchema,
@@ -926,13 +845,8 @@ export const mixColorsSchema = toolSchema<MixColorsParams>(
},
);
const mixColorsTool: ToolEntry<MixColorsParams> = {
const mixColorsTool: Tool<MixColorsParams> = {
id: "mix-colors-png",
slug: "mix-colors-png",
title: "Mix Colors PNG",
description:
"Averages the selected colors into one swatch. Colors become one uniform fill.",
category: "generate",
schema: mixColorsSchema,
input: "none",
run: genTool((p) => renderSwatches([mixColors(p.colors)], p.width, "strip")),
@@ -997,13 +911,8 @@ export const sortColorsSchema = toolSchema<SortColorsParams>(
},
);
const sortColorsTool: ToolEntry<SortColorsParams> = {
const sortColorsTool: Tool<SortColorsParams> = {
id: "sort-colors-png",
slug: "sort-colors-png",
title: "Sort Colors PNG",
description:
"Renders the chosen colors as swatches sorted by hue, brightness or saturation.",
category: "generate",
schema: sortColorsSchema,
input: "none",
run: genTool((p) =>
@@ -1065,13 +974,8 @@ export const textToPngSchema = toolSchema<TextToPngParams>(
},
);
const textToPng: ToolEntry<TextToPngParams> = {
const textToPng: Tool<TextToPngParams> = {
id: "text-to-png",
slug: "text-to-png",
title: "Text to PNG",
description:
"Creates a PNG image from text: the canvas is sized to fit the label plus padding.",
category: "generate",
domOnly: true,
schema: textToPngSchema,
input: "none",
@@ -1089,7 +993,7 @@ const textToPng: ToolEntry<TextToPngParams> = {
),
};
export const generateEntries = [
export const generateTools = [
createEmpty,
singleColor,
randomNoise,
@@ -3,8 +3,8 @@ import {
skewImage,
transformImage,
zoomImage,
} from "../core/affine";
import { ToolError } from "../core/errors";
} from "../../core/affine";
import { ToolError } from "../../core/errors";
import {
centerByAlpha,
changeCanvasSize,
@@ -22,14 +22,14 @@ import {
trimToContent,
type Anchor9,
type FlipAxis,
} from "../core/geometry";
import { field, toolSchema, type Dimension } from "../registry-schema";
} from "../../core/geometry";
import { field, toolSchema, type Dimension } from "../../registry-schema";
import {
imgTool,
requireSource,
type ToolEntry,
type Tool,
type ToolImageFile,
} from "./types";
} from "../types";
interface AddBorderParams {
thickness: number;
@@ -47,13 +47,8 @@ export const addBorderSchema = toolSchema<AddBorderParams>({
color: field.color({ label: "fields.borderColor", default: "#000000" }),
});
const addBorder: ToolEntry<AddBorderParams> = {
const addBorder: Tool<AddBorderParams> = {
id: "add-border-png",
slug: "add-border-png",
title: "Add border to PNG",
description:
"Draws a colored frame of the chosen thickness around the image.",
category: "geometry",
schema: addBorderSchema,
input: "image",
run: imgTool((img, p) =>
@@ -99,13 +94,8 @@ export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
},
);
const fitOnBackground: ToolEntry<FitOnBackgroundParams> = {
const fitOnBackground: Tool<FitOnBackgroundParams> = {
id: "fit-on-background-png",
slug: "fit-on-background-png",
title: "Fit PNG onto background",
description:
"Places the image centered on a canvas of the given size with a transparent or colored background.",
category: "geometry",
schema: fitOnBackgroundSchema,
input: "image",
run: imgTool((img, p) => {
@@ -167,13 +157,8 @@ export const changeCanvasSizeSchema = toolSchema<ChangeCanvasSizeParams>(
},
);
const changeCanvasSizeTool: ToolEntry<ChangeCanvasSizeParams> = {
const changeCanvasSizeTool: Tool<ChangeCanvasSizeParams> = {
id: "change-canvas-size-png",
slug: "change-canvas-size-png",
title: "Change Canvas Size PNG",
description:
"Sets the exact canvas size: overflow is cropped, missing space is filled with transparency. Anchor picks which part of the image stays.",
category: "geometry",
schema: changeCanvasSizeSchema,
input: "image",
run: imgTool((img, p) =>
@@ -213,13 +198,8 @@ export const resizeSchema = toolSchema<ResizeParams>(
},
);
const resizeTool: ToolEntry<ResizeParams> = {
const resizeTool: Tool<ResizeParams> = {
id: "resize-png",
slug: "resize-png",
title: "Resize PNG",
description:
"Scales the image with bilinear interpolation. With aspect kept, one side defines the scale; if both are set, the image fits inside them.",
category: "geometry",
schema: resizeSchema,
input: "image",
run: imgTool((img, p) => {
@@ -285,13 +265,8 @@ export const cropSchema = toolSchema<CropParams>(
},
);
const cropTool: ToolEntry<CropParams> = {
const cropTool: Tool<CropParams> = {
id: "crop-png",
slug: "crop-png",
title: "Crop PNG",
description:
"Cuts out a rectangular area. Coordinates and sizes may go beyond the image — the area is clipped to the intersection.",
category: "geometry",
schema: cropSchema,
input: "image",
run: imgTool((img, p) => {
@@ -320,12 +295,8 @@ export const rotateSchema = toolSchema<RotateParams>({
}),
});
const rotateTool: ToolEntry<RotateParams> = {
const rotateTool: Tool<RotateParams> = {
id: "rotate-png",
slug: "rotate-png",
title: "Rotate PNG",
description: "Rotates by 90°, 180° or 270° clockwise without quality loss.",
category: "geometry",
schema: rotateSchema,
input: "image",
run: imgTool((img, p) => rotate90(img, Number(p.angle) / 90)),
@@ -346,12 +317,8 @@ export const flipSchema = toolSchema<FlipParams>({
}),
});
const flipTool: ToolEntry<FlipParams> = {
const flipTool: Tool<FlipParams> = {
id: "flip-png",
slug: "flip-png",
title: "Flip PNG",
description: "Mirrors horizontally or vertically without quality loss.",
category: "geometry",
schema: flipSchema,
input: "image",
run: imgTool((img, p) => flip(img, p.axis)),
@@ -385,13 +352,8 @@ export const addPaddingSchema = toolSchema<AddPaddingParams>(
},
);
const addPaddingTool: ToolEntry<AddPaddingParams> = {
const addPaddingTool: Tool<AddPaddingParams> = {
id: "add-padding-png",
slug: "add-padding-png",
title: "Add padding to PNG",
description:
"Expands the canvas on all sides by the chosen number of pixels.",
category: "geometry",
schema: addPaddingSchema,
input: "image",
run: imgTool((img, p) =>
@@ -428,12 +390,8 @@ export const tileSchema = toolSchema<TileParams>({
}),
});
const tileTool: ToolEntry<TileParams> = {
const tileTool: Tool<TileParams> = {
id: "tile-png",
slug: "tile-png",
title: "Tile PNG",
description: "Repeats the image in a grid of the chosen columns and rows.",
category: "geometry",
schema: tileSchema,
input: "image",
run: imgTool((img, p) => tile(img, p.columns, p.rows)),
@@ -463,13 +421,8 @@ export const splitPartsSchema = toolSchema<SplitPartsParams>({
}),
});
const splitPartsTool: ToolEntry<SplitPartsParams> = {
const splitPartsTool: Tool<SplitPartsParams> = {
id: "split-into-parts-png",
slug: "split-into-parts-png",
title: "Split PNG into parts",
description:
"Divides the image into a grid of equal-sized parts. The canvas is padded with transparency to keep every part the same size.",
category: "geometry",
schema: splitPartsSchema,
input: "image",
result: "files",
@@ -501,13 +454,8 @@ interface EmptyParams {}
export const centerByAlphaSchema = toolSchema<EmptyParams>({});
const centerByAlphaTool: ToolEntry<EmptyParams> = {
const centerByAlphaTool: Tool<EmptyParams> = {
id: "center-by-alpha-png",
slug: "center-by-alpha-png",
title: "Center PNG by content",
description:
"Finds the opaque part of the image and centers it on the original canvas.",
category: "geometry",
schema: centerByAlphaSchema,
input: "image",
run: imgTool((img) => centerByAlpha(img)),
@@ -535,13 +483,8 @@ export const skewSchema = toolSchema<SkewParams>({
}),
});
const skewTool: ToolEntry<SkewParams> = {
const skewTool: Tool<SkewParams> = {
id: "skew-png",
slug: "skew-png",
title: "Skew PNG",
description:
"Shifts content horizontally and vertically — a perspective effect.",
category: "geometry",
schema: skewSchema,
input: "image",
run: imgTool((img, p) => skewImage(img, p.degX, p.degY)),
@@ -561,13 +504,8 @@ export const rotateFreeSchema = toolSchema<RotateFreeParams>({
}),
});
const rotateFreeTool: ToolEntry<RotateFreeParams> = {
const rotateFreeTool: Tool<RotateFreeParams> = {
id: "rotate-free-png",
slug: "rotate-free-png",
title: "Rotate by custom angle",
description:
"Rotation by any angle. The canvas grows to fit the new bounds; corners stay transparent.",
category: "geometry",
schema: rotateFreeSchema,
input: "image",
run: imgTool((img, p) => rotateFreeImage(img, p.angle)),
@@ -587,13 +525,8 @@ export const zoomSchema = toolSchema<ZoomParams>({
}),
});
const zoomTool: ToolEntry<ZoomParams> = {
const zoomTool: Tool<ZoomParams> = {
id: "zoom-png",
slug: "zoom-png",
title: "Zoom PNG",
description:
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
category: "geometry",
schema: zoomSchema,
input: "image",
run: imgTool((img, p) => zoomImage(img, p.scale)),
@@ -613,13 +546,8 @@ export const trimEmptySpaceSchema = toolSchema<TrimEmptySpaceParams>({
}),
});
const trimEmptySpaceTool: ToolEntry<TrimEmptySpaceParams> = {
const trimEmptySpaceTool: Tool<TrimEmptySpaceParams> = {
id: "trim-empty-space-png",
slug: "trim-empty-space-png",
title: "Trim Empty Space PNG",
description:
"Crops transparent borders around the content. Pixels with alpha above the threshold count as content.",
category: "geometry",
schema: trimEmptySpaceSchema,
input: "image",
run: imgTool((img, p) => trimToContent(img, p.threshold)),
@@ -656,13 +584,8 @@ export const changeAspectRatioSchema = toolSchema<ChangeAspectRatioParams>({
}),
});
const changeAspectRatioTool: ToolEntry<ChangeAspectRatioParams> = {
const changeAspectRatioTool: Tool<ChangeAspectRatioParams> = {
id: "change-aspect-ratio-png",
slug: "change-aspect-ratio-png",
title: "Change Aspect Ratio PNG",
description:
"Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.",
category: "geometry",
schema: changeAspectRatioSchema,
input: "image",
run: imgTool((img, p) => {
@@ -687,13 +610,8 @@ export const swapOrientationSchema = toolSchema<SwapOrientationParams>({
}),
});
const swapOrientationTool: ToolEntry<SwapOrientationParams> = {
const swapOrientationTool: Tool<SwapOrientationParams> = {
id: "swap-orientation-png",
slug: "swap-orientation-png",
title: "Swap Orientation PNG",
description:
"Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.",
category: "geometry",
schema: swapOrientationSchema,
input: "image",
run: imgTool((img, p) => forceOrientation(img, p.target)),
@@ -728,13 +646,8 @@ export const symmetricCopySchema = toolSchema<SymmetricCopyParams>({
}),
});
const symmetricCopyTool: ToolEntry<SymmetricCopyParams> = {
const symmetricCopyTool: Tool<SymmetricCopyParams> = {
id: "symmetric-copy-png",
slug: "symmetric-copy-png",
title: "Symmetric Copy PNG",
description:
"Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.",
category: "geometry",
schema: symmetricCopySchema,
input: "image",
run: imgTool((img, p) => symmetricCopy(img, p.axis, p.keepSide)),
@@ -764,12 +677,8 @@ export const shiftSchema = toolSchema<ShiftParams>({
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
});
const shiftTool: ToolEntry<ShiftParams> = {
const shiftTool: Tool<ShiftParams> = {
id: "shift-png",
slug: "shift-png",
title: "Shift PNG",
description: "Moves content by the given X and Y offset.",
category: "geometry",
schema: shiftSchema,
input: "image",
run: imgTool((img, p) =>
@@ -783,7 +692,7 @@ const shiftTool: ToolEntry<ShiftParams> = {
),
};
export const geometryEntries = [
export const geometryTools = [
addBorder,
fitOnBackground,
changeCanvasSizeTool,
@@ -1,9 +1,9 @@
import { formatStamp } from "../core/datefmt";
import { drawTextBlock, drawTextTile } from "../core/domText";
import type { Position9 } from "../core/textdraw";
import type { FontStyle, Plate } from "../registry-schema";
import { field, toolSchema } from "../registry-schema";
import { imgTool, type ToolEntry } from "./types";
import { formatStamp } from "../../core/datefmt";
import { drawTextBlock, drawTextTile } from "../../core/domText";
import type { Position9 } from "../../core/textdraw";
import type { FontStyle, Plate } from "../../registry-schema";
import { field, toolSchema } from "../../registry-schema";
import { imgTool, type Tool } from "../types";
interface AddTextParams {
text: string;
@@ -58,13 +58,8 @@ const addTextSchema = toolSchema<AddTextParams>(
},
);
const addText: ToolEntry<AddTextParams> = {
const addText: Tool<AddTextParams> = {
id: "add-text-png",
slug: "add-text-png",
title: "Add text to PNG",
description:
"Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.",
category: "text",
domOnly: true,
schema: addTextSchema,
input: "image",
@@ -137,13 +132,8 @@ const dateStampSchema = toolSchema<DateStampParams>(
},
);
const dateStamp: ToolEntry<DateStampParams> = {
const dateStamp: Tool<DateStampParams> = {
id: "date-stamp-png",
slug: "date-stamp-png",
title: "Date stamp PNG",
description:
"Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.",
category: "text",
domOnly: true,
schema: dateStampSchema,
input: "image",
@@ -231,13 +221,8 @@ const watermarkTileSchema = toolSchema<WatermarkTileParams>(
},
);
const watermarkTile: ToolEntry<WatermarkTileParams> = {
const watermarkTile: Tool<WatermarkTileParams> = {
id: "watermark-tile-png",
slug: "watermark-tile-png",
title: "Watermark Tile PNG",
description:
"Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.",
category: "text",
domOnly: true,
schema: watermarkTileSchema,
input: "image",
@@ -256,4 +241,4 @@ const watermarkTile: ToolEntry<WatermarkTileParams> = {
),
};
export const textEntries = [addText, dateStamp, watermarkTile];
export const textTools = [addText, dateStamp, watermarkTile];
+32 -16
View File
@@ -63,23 +63,18 @@ export interface VerdictResult {
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
/**
* Инструмент нового registry: полностью типизирован на `Params`, схема —
* обязательна (единый источник дефолтов/валидации/UI). У каждого инструмента
* ровно один метод `run(ctx)`; контракт входа декларируется через `input`
* (обязательное поле), а результат — через `result`. Отдельных методов
* `generate`/`runFromText`/`toText`/`textToText` нет.
* Инструмент: уникальная реализация без адреса, текстов и категории — всё это
* у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
* одного инструмента на две страницы хватает, а страница из нескольких
* инструментов — это уже цепочка. Схема обязательна (единый источник
* дефолтов/валидации/UI). У каждого инструмента ровно один метод `run(ctx)`;
* контракт входа декларируется через `input` (обязательное поле), а результат —
* через `result`. Отдельных методов `generate`/`runFromText`/`toText`/
* `textToText` нет.
*/
export type ToolEntry<P = Record<string, unknown>> = {
/** Внутреннее имя: ключ i18n `tools.<id>.*`, `toolId` worker-протокола, запись пайплайна. */
export type Tool<P = Record<string, unknown>> = {
/** Внутреннее имя: ключ словаря инструмента, `toolId` worker-протокола, запись пайплайна. */
id: string;
/**
* Адрес страницы `/tools/<slug>` и база имени скачиваемого файла. Инструмент
* работает с любым форматом, поэтому png-интент держит slug, а не id.
*/
slug: string;
title: string;
description: string;
category: CategoryId;
schema: ToolSchema<P>;
/** Чем управляется инструмент: входным изображением, текстом или ничем. */
input: InputMode;
@@ -91,7 +86,28 @@ export type ToolEntry<P = Record<string, unknown>> = {
domOnly?: boolean;
/** Формат/качество скачивания результата; по умолчанию — PNG. */
output?: OutputFormat;
icon?: string;
};
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */
export type PageStep = {
id: string;
params?: Record<string, unknown>;
};
/**
* Страница: уникальный адрес, тексты и то, что на ней выполняется. `steps` —
* упорядоченный список инструментов, потому что страница может быть готовой
* цепочкой; исполняет их `SchemaToolView`, и на сегодня шаг ровно один.
*/
export type Page = {
/** Адрес `/tools/<slug>`, имя скачиваемого файла и ключ иконки. */
slug: string;
/** EN-строка страницы; перевод лежит в словаре по slug. */
title: string;
/** EN-строка страницы; перевод лежит в словаре по slug. */
description: string;
category: CategoryId;
steps: PageStep[];
};
/** Гарантирует наличие входного изображения для трансформаторов. */
+4 -4
View File
@@ -4,9 +4,9 @@
import Footer from "$lib/components/layout/Footer.svelte";
import TopBar from "$lib/components/layout/TopBar.svelte";
import { initLocale } from "$lib/i18n/locale.svelte";
import { toolTitle } from "$lib/i18n/schema-tool-strings";
import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { getTool } from "$lib/registry";
import { getPageBySlug } from "$lib/registry";
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
import type { Snippet } from "svelte";
import { onMount } from "svelte";
@@ -37,8 +37,8 @@
if (path === "/kit") return "/ " + t("header.uiKit");
const match = /^\/tools\/([^/]+)$/.exec(path);
if (match) {
const tool = getTool(match[1]);
if (tool) return "/ " + toolTitle(tool);
const page = getPageBySlug(match[1]);
if (page) return "/ " + pageTitle(page);
}
return "/ " + t("header.home");
}
+11 -11
View File
@@ -5,9 +5,9 @@
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
import ToolCard from "$lib/components/ToolCard.svelte";
import {
searchTools,
toolDescription,
toolTitle,
pageDescription,
pageTitle,
searchPages,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { TOOL_ICONS } from "$lib/tool-icons";
@@ -19,10 +19,10 @@
PREVIEW_GROUPS.map((g) => ({
id: g.id,
label: t(`categories.${g.id}`),
tools: searchTools(g.tools, query).filter(
pages: searchPages(g.pages, query).filter(
() => category === "all" || category === g.id,
),
})).filter((g) => g.tools.length > 0),
})).filter((g) => g.pages.length > 0),
);
</script>
@@ -31,14 +31,14 @@
<CatalogToolbar bind:query bind:category />
<div class="catalog-groups">
{#each groups as group (group.id)}
<CatalogGroup label={group.label} count={group.tools.length}>
{#each group.tools as tool, i (tool.id)}
<CatalogGroup label={group.label} count={group.pages.length}>
{#each group.pages as page, i (page.slug)}
<ToolCard
title={toolTitle(tool)}
id={tool.id}
description={toolDescription(tool)}
title={pageTitle(page)}
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[tool.id]}
icon={TOOL_ICONS[page.slug]}
/>
{/each}
</CatalogGroup>
+8 -8
View File
@@ -23,7 +23,7 @@
import Toggle from "$lib/components/ui/Toggle.svelte";
import { Download, ImageOff, Plus, Trash2 } from "@lucide/svelte";
import { TOOLS } from "$lib/registry";
import { PAGES } from "$lib/registry";
import { TOOL_ICONS } from "$lib/tool-icons";
let mode = $state("preview");
@@ -34,8 +34,8 @@
let lossless = $state(true);
let animated = $state(false);
const showcaseTools = TOOLS.filter((tool) =>
["linear-gradient-png", "resize-png", "quantize-png"].includes(tool.id),
const showcasePages = PAGES.filter((page) =>
["linear-gradient-png", "resize-png", "quantize-png"].includes(page.slug),
);
</script>
@@ -140,13 +140,13 @@
<Panel title="Tools" eyebrow="catalog">
<div class="tool-grid">
{#each showcaseTools as tool, i (tool.id)}
{#each showcasePages as page, i (page.slug)}
<ToolCard
title={tool.title}
id={tool.id}
description={tool.description}
title={page.title}
slug={page.slug}
description={page.description}
index={i + 1}
icon={TOOL_ICONS[tool.id]}
icon={TOOL_ICONS[page.slug]}
/>
{/each}
</div>
+11 -11
View File
@@ -5,9 +5,9 @@
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
import ToolCard from "$lib/components/ToolCard.svelte";
import {
searchTools,
toolDescription,
toolTitle,
pageDescription,
pageTitle,
searchPages,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { TOOL_ICONS } from "$lib/tool-icons";
@@ -19,10 +19,10 @@
PREVIEW_GROUPS.map((g) => ({
id: g.id,
label: t(`categories.${g.id}`),
tools: searchTools(g.tools, query).filter(
pages: searchPages(g.pages, query).filter(
() => category === "all" || category === g.id,
),
})).filter((g) => g.tools.length > 0),
})).filter((g) => g.pages.length > 0),
);
</script>
@@ -31,14 +31,14 @@
<CatalogToolbar bind:query bind:category />
<div class="catalog-groups">
{#each groups as group (group.id)}
<CatalogGroup label={group.label} count={group.tools.length}>
{#each group.tools as tool, i (tool.id)}
<CatalogGroup label={group.label} count={group.pages.length}>
{#each group.pages as page, i (page.slug)}
<ToolCard
title={toolTitle(tool)}
id={tool.id}
description={toolDescription(tool)}
title={pageTitle(page)}
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[tool.id]}
icon={TOOL_ICONS[page.slug]}
/>
{/each}
</CatalogGroup>
+6 -5
View File
@@ -2,7 +2,7 @@
import EmptyState from "$lib/components/EmptyState.svelte";
import SchemaToolView from "$lib/components/SchemaToolView.svelte";
import { t } from "$lib/i18n/t";
import { getTool } from "$lib/registry";
import { getPageBySlug, getTool } from "$lib/registry";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
interface Props {
@@ -10,15 +10,16 @@
}
let { data }: Props = $props();
const tool = $derived(getTool(data.id));
const page = $derived(getPageBySlug(data.id));
const tool = $derived(page ? getTool(page.steps[0].id) : undefined);
</script>
<svelte:head>
<title>easy-png-tools / {tool?.title ?? t("toolPage.fallbackTitle")}</title>
<title>easy-png-tools / {page?.title ?? t("toolPage.fallbackTitle")}</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
{#if !tool}
{#if !page || !tool}
<div class="notfound">
<EmptyState
title={t("errors.notFound")}
@@ -27,7 +28,7 @@
/>
</div>
{:else}
<SchemaToolView {tool} />
<SchemaToolView {page} {tool} />
{/if}
<style>
+2 -2
View File
@@ -1,9 +1,9 @@
import { TOOLS } from "$lib/registry";
import { PAGES } from "$lib/registry";
export const prerender = true;
export function entries() {
return TOOLS.map((tool) => ({ id: tool.id }));
return PAGES.map((page) => ({ id: page.slug }));
}
export function load({ params }: { params: { id: string } }) {