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 { CATEGORY_IDS } from "./categories";
import { TOOLS, type ToolEntry } from "./registry"; import { PAGES, type Page } from "./registry";
export type PreviewGroup = { export type PreviewGroup = {
id: string; id: string;
tools: ToolEntry[]; pages: Page[];
}; };
/** /**
* Preview-каталог: полный реестр инструментов, сгруппированный по категориям. * Preview-каталог: все страницы, сгруппированные по категориям.
*/ */
export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({ export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
id: category, id: category,
tools: TOOLS.filter((tool) => tool.category === category), pages: PAGES.filter((page) => page.category === category),
})).filter((group) => group.tools.length > 0); })).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 type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor"; import { execute } from "$lib/executor";
import { import {
toolDescription, pageDescription,
toolTitle, pageTitle,
verdictText, verdictText,
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FileResult, ToolEntry } from "$lib/registry"; import type { FileResult, Page, Tool } from "$lib/registry";
import { import {
applySourceDefaults, applySourceDefaults,
defaultSchemaParams, defaultSchemaParams,
@@ -22,12 +22,13 @@
import { downloadZip } from "$lib/zip"; import { downloadZip } from "$lib/zip";
interface Props { interface Props {
tool: ToolEntry; page: Page;
tool: Tool;
} }
type DisplayError = type DisplayError =
| { kind: "i18n"; key: string; vars?: ErrorVars } | { kind: "i18n"; key: string; vars?: ErrorVars }
| { kind: "plain"; text: string }; | { kind: "plain"; text: string };
let { tool }: Props = $props(); let { page, tool }: Props = $props();
const schema = $derived( const schema = $derived(
tool.schema as ToolSchema<Record<string, unknown>> | undefined, tool.schema as ToolSchema<Record<string, unknown>> | undefined,
@@ -141,7 +142,7 @@
try { try {
if (resultKind === "files") { if (resultKind === "files") {
if (!fileResult) return; if (!fileResult) return;
await downloadZip(fileResult.files, `${tool.id}.zip`); await downloadZip(fileResult.files, `${page.slug}.zip`);
return; return;
} }
if (!result) return; if (!result) return;
@@ -150,7 +151,7 @@
? Number(values[out.qualityParamId]) / 100 ? Number(values[out.qualityParamId]) / 100
: undefined; : undefined;
const blob = await encode(result, out?.mime ?? "image/png", quality); 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) { } catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
} finally { } finally {
@@ -175,7 +176,7 @@
if (!textResult) return; if (!textResult) return;
try { try {
const blob = new Blob([textResult], { type: "text/plain" }); const blob = new Blob([textResult], { type: "text/plain" });
downloadBlob(blob, `${tool.id}.txt`); downloadBlob(blob, `${page.slug}.txt`);
} catch (e) { } catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
} }
@@ -208,8 +209,8 @@
<div class="schema-tool"> <div class="schema-tool">
<header class="header"> <header class="header">
<div class="title-block"> <div class="title-block">
<h1>{toolTitle(tool)}</h1> <h1>{pageTitle(page)}</h1>
<p class="lede">{toolDescription(tool)}</p> <p class="lede">{pageDescription(page)}</p>
</div> </div>
</header> </header>
+3 -3
View File
@@ -6,15 +6,15 @@
interface Props { interface Props {
title: string; title: string;
id: string; slug: string;
description?: string; description?: string;
index?: number; index?: number;
icon?: Component<{ size?: number; class?: string }>; icon?: Component<{ size?: number; class?: string }>;
} }
let { title, id, description, index, icon }: Props = $props(); let { title, slug, description, index, icon }: Props = $props();
</script> </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} {#if icon}<span class="tool-icon"><Icon {icon} size={19} /></span>{/if}
<span class="tool-copy"> <span class="tool-copy">
<strong>{title}</strong> <strong>{title}</strong>
+4 -8
View File
@@ -15,7 +15,7 @@ import {
} from "./protocol"; } from "./protocol";
import { handleWorkerRequest } from "./worker-handler"; import { handleWorkerRequest } from "./worker-handler";
import { field, toolSchema, type Dimension } from "../registry-schema"; import { field, toolSchema, type Dimension } from "../registry-schema";
import type { ToolEntry } from "../registry"; import type { Tool } from "../registry";
class FakeWorker implements WorkerLike { class FakeWorker implements WorkerLike {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null; onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
@@ -61,15 +61,11 @@ function createFactory(throwOnPost = false): {
const emptySchema = toolSchema<Record<string, never>>({}); const emptySchema = toolSchema<Record<string, never>>({});
function makeTool( function makeTool(
run: ToolEntry["run"] = () => "direct", run: Tool["run"] = () => "direct",
overrides: Partial<ToolEntry> = {}, overrides: Partial<Tool> = {},
): ToolEntry { ): Tool {
return { return {
id: "test-tool", id: "test-tool",
slug: "test-tool",
title: "Test tool",
description: "Test tool",
category: "analyze",
schema: emptySchema, schema: emptySchema,
input: "none", input: "none",
run, run,
+5 -8
View File
@@ -1,6 +1,6 @@
import { ToolError } from "../core/errors"; import { ToolError } from "../core/errors";
import type { PixelImage } from "../core/types"; 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 { sanitizeSchemaParams } from "../registry-schema";
import { import {
decodeWorkerResponse, decodeWorkerResponse,
@@ -37,7 +37,7 @@ function defaultWorkerFactory(): WorkerLike {
export function createExecutor( export function createExecutor(
options: { workerFactory?: WorkerFactory } = {}, options: { workerFactory?: WorkerFactory } = {},
): (tool: ToolEntry, ctx: ExecuteContext) => Promise<ToolResult> { ): (tool: Tool, ctx: ExecuteContext) => Promise<ToolResult> {
const workerFactory = options.workerFactory ?? defaultWorkerFactory; const workerFactory = options.workerFactory ?? defaultWorkerFactory;
let worker: WorkerLike | null = null; let worker: WorkerLike | null = null;
let workerTried = false; let workerTried = false;
@@ -102,7 +102,7 @@ export function createExecutor(
} }
async function runDirect( async function runDirect(
tool: ToolEntry, tool: Tool,
ctx: ExecuteContext, ctx: ExecuteContext,
): Promise<ToolResult> { ): Promise<ToolResult> {
return await tool.run(ctx as ToolContext<Record<string, unknown>>); return await tool.run(ctx as ToolContext<Record<string, unknown>>);
@@ -110,7 +110,7 @@ export function createExecutor(
function runInWorker( function runInWorker(
candidate: WorkerLike, candidate: WorkerLike,
tool: ToolEntry, tool: Tool,
ctx: ExecuteContext, ctx: ExecuteContext,
): Promise<ToolResult> { ): Promise<ToolResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -136,10 +136,7 @@ export function createExecutor(
}); });
} }
async function route( async function route(tool: Tool, ctx: ExecuteContext): Promise<ToolResult> {
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
if (tool.domOnly) return await runDirect(tool, ctx); if (tool.domOnly) return await runDirect(tool, ctx);
const candidate = ensureWorker(); const candidate = ensureWorker();
if (candidate === null) return await runDirect(tool, ctx); if (candidate === null) return await runDirect(tool, ctx);
+18 -30
View File
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { TOOLS } from "../registry"; import { PAGES } from "../registry";
import { setLocale } from "./locale.svelte"; import { setLocale } from "./locale.svelte";
import { import {
searchTools, pageDescription,
toolDescription, pageTitle,
toolTitle, searchPages,
verdictText, verdictText,
} from "./schema-tool-strings"; } from "./schema-tool-strings";
@@ -12,53 +12,41 @@ afterEach(() => {
setLocale("ru"); 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("пустой запрос возвращает весь каталог", () => { it("пустой запрос возвращает весь каталог", () => {
expect(searchTools(TOOLS, "")).toHaveLength(TOOLS.length); expect(searchPages(PAGES, "")).toHaveLength(PAGES.length);
expect(searchTools(TOOLS, " ")).toHaveLength(TOOLS.length); expect(searchPages(PAGES, " ")).toHaveLength(PAGES.length);
}); });
it("находит ru-название, даже когда локаль en", () => { it("находит ru-название, даже когда локаль en", () => {
setLocale("en"); setLocale("en");
expect(ids(searchTools(TOOLS, "обрез"))).toContain("crop-png"); expect(slugs(searchPages(PAGES, "обрез"))).toContain("crop-png");
expect(ids(searchTools(TOOLS, "пустые поля"))).toContain( expect(slugs(searchPages(PAGES, "пустые поля"))).toContain(
"trim-empty-space-png", "trim-empty-space-png",
); );
}); });
it("находит en-название, даже когда локаль ru", () => { it("находит en-название, даже когда локаль ru", () => {
expect(ids(searchTools(TOOLS, "resize"))).toContain("resize-png"); expect(slugs(searchPages(PAGES, "resize"))).toContain("resize-png");
expect(ids(searchTools(TOOLS, "round corners"))).toContain( expect(slugs(searchPages(PAGES, "round corners"))).toContain(
"round-corners-png", "round-corners-png",
); );
}); });
it("находит по id", () => { it("находит по slug (тексту адреса)", () => {
expect(ids(searchTools(TOOLS, "crop png"))).toContain("crop-png"); expect(slugs(searchPages(PAGES, "crop png"))).toContain("crop-png");
}); });
it("ё и е — один запрос", () => { it("ё и е — один запрос", () => {
const byYo = searchTools(TOOLS, "чёрно"); const byYo = searchPages(PAGES, "чёрно");
const byYe = searchTools(TOOLS, "черно"); const byYe = searchPages(PAGES, "черно");
expect(ids(byYo)).toEqual(ids(byYe)); expect(slugs(byYo)).toEqual(slugs(byYe));
}); });
it("нет совпадения — пустой список", () => { it("нет совпадения — пустой список", () => {
expect(searchTools(TOOLS, "квантовый тостер")).toEqual([]); expect(searchPages(PAGES, "квантовый тостер")).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,
);
}
}); });
}); });
+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 type { Field } from "$lib/registry-schema";
import { getMergedDict } from "./locale.svelte"; import { getMergedDict } from "./locale.svelte";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching"; import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
@@ -12,12 +12,12 @@ function labelOf(id: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase()); .replace(/\b\w/g, (c) => c.toUpperCase());
} }
export function toolTitle(tool: ToolEntry): string { export function pageTitle(page: Page): string {
return getMergedDict().tools[tool.id]?.title ?? tool.title; return getMergedDict().tools[page.slug]?.title ?? page.title;
} }
export function toolDescription(tool: ToolEntry): string { export function pageDescription(page: Page): string {
return getMergedDict().tools[tool.id]?.description ?? tool.description; return getMergedDict().tools[page.slug]?.description ?? page.description;
} }
export function fieldLabel(field: Field<unknown>, id: string): string { 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))]; return [...new Set(values.filter((v) => v.length > 0))];
} }
export function toolSearchDoc(tool: ToolEntry): SearchDoc { export function pageSearchDoc(page: Page): SearchDoc {
const active = getMergedDict().tools[tool.id]; const active = getMergedDict().tools[page.slug];
return { return {
id: tool.id, id: page.slug,
titles: dedupe([ titles: dedupe([
active?.title ?? "", active?.title ?? "",
ru.tools[tool.id]?.title ?? "", ru.tools[page.slug]?.title ?? "",
tool.title, page.title,
]), ]),
descriptions: dedupe([ descriptions: dedupe([
active?.description ?? "", active?.description ?? "",
ru.tools[tool.id]?.description ?? "", ru.tools[page.slug]?.description ?? "",
tool.description, page.description,
]), ]),
}; };
} }
/** /**
* Кросс-языковой поиск по каталогу: скор через `scoreDoc` на * Кросс-языковой поиск по каталогу: скор через `scoreDoc` на
* `toolSearchDoc` (заголовки/описания всех локалей). Порядок каталога * `pageSearchDoc` (заголовки/описания всех локалей). Порядок каталога
* сохраняется, элементы без совпадения отбрасываются. * сохраняется, элементы без совпадения отбрасываются.
*/ */
export function searchTools( export function searchPages(pages: readonly Page[], query: string): Page[] {
tools: readonly ToolEntry[],
query: string,
): ToolEntry[] {
const q = normalizeForSearch(query.trim()); const q = normalizeForSearch(query.trim());
if (q.length === 0) return [...tools]; if (q.length === 0) return [...pages];
return tools.filter((tool) => scoreDoc(toolSearchDoc(tool), q) !== null); 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 { LOCALE_TAGS } from "./dict";
import { normalizeForSearch } from "./matching"; import { normalizeForSearch } from "./matching";
import { t } from "./t"; import { t } from "./t";
import { getTool, TOOLS } from "../registry"; import { getPageBySlug, PAGES } from "../registry";
import { toolTitle } from "./schema-tool-strings"; import { pageTitle } from "./schema-tool-strings";
afterEach(() => { afterEach(() => {
setLocale("ru"); setLocale("ru");
@@ -52,11 +52,11 @@ describe("Смоук §6 i18n (новый UI)", () => {
}); });
it("4. Заголовок инструмента переключается локалями (schema-tool-strings)", () => { it("4. Заголовок инструмента переключается локалями (schema-tool-strings)", () => {
const tool = getTool("crop-png") ?? TOOLS[0]; const page = getPageBySlug("crop-png") ?? PAGES[0];
setLocale("ru"); setLocale("ru");
expect(toolTitle(tool)).toContain("Обрезать"); expect(pageTitle(page)).toContain("Обрезать");
setLocale("en"); setLocale("en");
expect(toolTitle(tool)).toBe(tool.title); expect(pageTitle(page)).toBe(page.title);
}); });
it("5. Ошибки с vars локализуются на оба языка", () => { it("5. Ошибки с vars локализуются на оба языка", () => {
+16 -13
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { TOOLS, type ToolEntry } from "../registry"; import { PAGES, TOOLS, type Tool } from "../registry";
import { en } from "./en"; import { en } from "./en";
import { ru } from "./ru"; import { ru } from "./ru";
function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } { function toolKeys(tool: Tool): { fields: string[]; groups: string[] } {
const fields: string[] = []; const fields: string[] = [];
for (const field of Object.values(tool.schema?.fields ?? {})) { for (const field of Object.values(tool.schema?.fields ?? {})) {
const label = (field as { spec?: { label?: string } }).spec?.label; 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 }; type SelectOption = { value: string; label: string };
function selectOptions( function selectOptions(
tool: ToolEntry, tool: Tool,
): { fieldId: string; option: SelectOption }[] { ): { fieldId: string; option: SelectOption }[] {
const result: { fieldId: string; option: SelectOption }[] = []; const result: { fieldId: string; option: SelectOption }[] = [];
for (const [fieldId, field] of Object.entries(tool.schema?.fields ?? {})) { for (const [fieldId, field] of Object.entries(tool.schema?.fields ?? {})) {
@@ -32,12 +32,12 @@ function selectOptions(
} }
describe("полнота словарей для нового registry", () => { describe("полнота словарей для нового registry", () => {
it("у каждого инструмента есть перевод ru с непустыми title/description", () => { it("у каждой страницы есть перевод ru с непустыми title/description", () => {
for (const tool of TOOLS) { for (const page of PAGES) {
const strings = ru.tools[tool.id]; const strings = ru.tools[page.slug];
expect(strings, `нет перевода ru для ${tool.id}`).toBeDefined(); expect(strings, `нет перевода ru для ${page.slug}`).toBeDefined();
expect(strings?.title, `${tool.id}: title`).toBeTruthy(); expect(strings?.title, `${page.slug}: title`).toBeTruthy();
expect(strings?.description, `${tool.id}: description`).toBeTruthy(); expect(strings?.description, `${page.slug}: description`).toBeTruthy();
} }
}); });
@@ -75,11 +75,14 @@ describe("полнота словарей для нового registry", () => {
} }
}); });
it("в словарях нет лишних инструментов", () => { it("в словарях нет ключей без записи в реестре", () => {
const ids = new Set(TOOLS.map((tool) => tool.id)); const known = new Set([
...TOOLS.map((tool) => tool.id),
...PAGES.map((page) => page.slug),
]);
for (const dict of [ru.tools, en.tools]) { for (const dict of [ru.tools, en.tools]) {
for (const id of Object.keys(dict)) { for (const key of Object.keys(dict)) {
expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true); expect(known.has(key), `лишний ключ в словаре: ${key}`).toBe(true);
} }
} }
}); });
+45 -24
View File
@@ -1,12 +1,20 @@
import { alphaEntries } from "./alpha"; import { alphaTools } from "./tools/alpha";
import { analyzeEntries } from "./analyze"; import { analyzeTools } from "./tools/analyze";
import { colorEntries } from "./color"; import { colorTools } from "./tools/color";
import { convertEntries } from "./convert"; import { convertTools } from "./tools/convert";
import { filtersEntries } from "./filters"; import { filtersTools } from "./tools/filters";
import { generateEntries } from "./generate"; import { generateTools } from "./tools/generate";
import { geometryEntries } from "./geometry"; import { geometryTools } from "./tools/geometry";
import { textEntries } from "./text"; import { textTools } from "./tools/text";
import type { ToolEntry } from "./types"; 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 { export {
genTool, genTool,
@@ -20,30 +28,43 @@ export {
export type { export type {
FileResult, FileResult,
InputMode, InputMode,
Page,
PageStep,
ResultKind, ResultKind,
Tool,
ToolContext, ToolContext,
ToolEntry,
ToolImageFile, ToolImageFile,
ToolResult, ToolResult,
VerdictResult, VerdictResult,
} from "./types"; } from "./types";
export const TOOLS: ToolEntry[] = [ export const TOOLS: Tool[] = [
...geometryEntries, ...geometryTools,
...alphaEntries, ...alphaTools,
...convertEntries, ...convertTools,
...analyzeEntries, ...analyzeTools,
...filtersEntries, ...filtersTools,
...colorEntries, ...colorTools,
...generateEntries, ...generateTools,
...textEntries, ...textTools,
] as unknown as ToolEntry[]; ] 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); return TOOLS.find((tool) => tool.id === id);
} }
/** Поиск по адресу страницы; `getTool` остаётся поиском по внутреннему имени. */ /** Единственный способ достать страницу: маршрут, крошка и имя файла идут от slug. */
export function getToolBySlug(slug: string): ToolEntry | undefined { export function getPageBySlug(slug: string): Page | undefined {
return TOOLS.find((tool) => tool.slug === slug); 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 { describe, expect, it } from "vitest";
import { TOOLS } from "."; import { PAGES, TOOLS } from ".";
import { PREVIEW_GROUPS } from "../catalog"; import { PREVIEW_GROUPS } from "../catalog";
import type { PixelImage } from "../core/types"; import type { PixelImage } from "../core/types";
import type { OutputMime } from "../core/io"; import type { OutputMime } from "../core/io";
@@ -14,13 +14,13 @@ function asImage(result: ToolResult): PixelImage {
} }
describe("registry-new (переведённые инструменты)", () => { describe("registry-new (переведённые инструменты)", () => {
it("id уникальны", () => { it("id инструментов уникальны", () => {
const ids = TOOLS.map((t) => t.id); const ids = TOOLS.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
}); });
it("slug уникальны", () => { it("slug страниц уникальны", () => {
const slugs = TOOLS.map((t) => t.slug); const slugs = PAGES.map((page) => page.slug);
expect(new Set(slugs).size).toBe(slugs.length); 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]+)*$/; const shape = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
for (const tool of TOOLS) { for (const tool of TOOLS) {
expect(shape.test(tool.id), `id: ${tool.id}`).toBe(true); 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 строится без ошибок", () => { it("PREVIEW_GROUPS строится без ошибок", () => {
expect(PREVIEW_GROUPS.length).toBeGreaterThan(0); expect(PREVIEW_GROUPS.length).toBeGreaterThan(0);
expect(PREVIEW_GROUPS.reduce((n, g) => n + g.tools.length, 0)).toBe( expect(PREVIEW_GROUPS.reduce((n, g) => n + g.pages.length, 0)).toBe(
TOOLS.length, PAGES.length,
); );
}); });
+16 -16
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { TOOLS } from "."; import { PAGES } from ".";
const MAP_PATH = fileURLToPath( const MAP_PATH = fileURLToPath(
new URL("../../../../docs/tools-map.md", import.meta.url), new URL("../../../../docs/tools-map.md", import.meta.url),
@@ -10,7 +10,7 @@ const MAP_PATH = fileURLToPath(
const SECTION_START = /^## 1\.\s/; const SECTION_START = /^## 1\.\s/;
const SECTION_END = /^## 2\.\s/; const SECTION_END = /^## 2\.\s/;
// Все id в реестре содержат дефис, поэтому формат строгий: это отсекает слова // Все slug страниц содержат дефис, поэтому формат строгий: это отсекает слова
// описания вроде `median-cut` из «colors (k, median-cut)». // описания вроде `median-cut` из «colors (k, median-cut)».
const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/; const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
@@ -18,9 +18,9 @@ type Mention = { id: string; line: number };
/** /**
* Буллиты раздела «1. Реализовано» вместе с номером первой строки. * Буллиты раздела «1. Реализовано» вместе с номером первой строки.
* Перенесённые строки приклеиваются к буллету: иначе id, оказавшийся на * Перенесённые строки приклеиваются к буллету: иначе slug, оказавшийся на
* следующей строке, молча выпадал бы из разбора и инструмент считался бы * следующей строке, молча выпадал бы из разбора и страница считалась бы
* неописанным. * неописанной.
*/ */
function implementedBullets( function implementedBullets(
markdown: string, markdown: string,
@@ -56,7 +56,7 @@ function implementedBullets(
} }
/** /**
* Всё до первого тире `—` — id через `/` или `,`; после тире свободное * Всё до первого тире `—` — slug через `/` или `,`; после тире свободное
* описание параметров, оно не разбирается. Формат закреплён в самой карте. * описание параметров, оно не разбирается. Формат закреплён в самой карте.
*/ */
function mentionedIds(markdown: string): Mention[] { function mentionedIds(markdown: string): Mention[] {
@@ -71,39 +71,39 @@ function mentionedIds(markdown: string): Mention[] {
return found; return found;
} }
describe("docs/tools-map.md ↔ реестр", () => { describe("docs/tools-map.md ↔ страницы реестра", () => {
const markdown = readFileSync(MAP_PATH, "utf8"); const markdown = readFileSync(MAP_PATH, "utf8");
const mentioned = mentionedIds(markdown); 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(implementedBullets(markdown).length).toBeGreaterThan(0);
expect(new Set(mentioned.map((m) => m.id)).size).toBeGreaterThan(50); expect(new Set(mentioned.map((m) => m.id)).size).toBeGreaterThan(50);
}); });
it("в карте нет id, которых нет в реестре", () => { it("в карте нет slug, которых нет в реестре", () => {
const bogus = mentioned.filter((m) => !registryIds.has(m.id)); const bogus = mentioned.filter((m) => !registrySlugs.has(m.id));
expect( expect(
bogus, bogus,
bogus bogus
.map( .map(
(m) => (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"), .join("\n"),
).toEqual([]); ).toEqual([]);
}); });
it("каждый инструмент реестра описан в карте", () => { it("каждая страница реестра описана в карте", () => {
const mentionedSet = new Set(mentioned.map((m) => m.id)); 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( expect(
missing, missing,
`не описаны в разделе «Реализовано»:\n${missing.join("\n")}`, `не описаны в разделе «Реализовано»:\n${missing.join("\n")}`,
).toEqual([]); ).toEqual([]);
}); });
it("id не продублированы между буллетами", () => { it("slug не продублированы между буллетами", () => {
const seen = new Map<string, number>(); const seen = new Map<string, number>();
const dupes: string[] = []; const dupes: string[] = [];
for (const { id, line } of mentioned) { for (const { id, line } of mentioned) {
@@ -114,6 +114,6 @@ describe("docs/tools-map.md ↔ реестр", () => {
seen.set(id, line); 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, removeColorToAlpha,
roundCorners, roundCorners,
setAlphaChannel, setAlphaChannel,
} from "../core/alpha"; } from "../../core/alpha";
import { removeBackground } from "../core/background"; import { removeBackground } from "../../core/background";
import { import {
closingImage, closingImage,
contourImage, contourImage,
@@ -15,18 +15,18 @@ import {
erodeImage, erodeImage,
openingImage, openingImage,
strokeImage, strokeImage,
} from "../core/morphology"; } from "../../core/morphology";
import { defringe, featherAlpha } from "../core/pixel-fx"; import { defringe, featherAlpha } from "../../core/pixel-fx";
import { import {
boxTest, boxTest,
circleTest, circleTest,
renderShape, renderShape,
starTest, starTest,
wavyTest, wavyTest,
} from "../core/shapes"; } from "../../core/shapes";
import type { Offset } from "../registry-schema"; import type { Offset } from "../../registry-schema";
import { field, toolSchema } from "../registry-schema"; import { field, toolSchema } from "../../registry-schema";
import { imgTool, type ToolEntry } from "./types"; import { imgTool, type Tool } from "../types";
interface AddStrokeParams { interface AddStrokeParams {
color: string; color: string;
@@ -44,13 +44,8 @@ export const addStrokeSchema = toolSchema<AddStrokeParams>({
}), }),
}); });
const addStroke: ToolEntry<AddStrokeParams> = { const addStroke: Tool<AddStrokeParams> = {
id: "add-stroke-png", 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, schema: addStrokeSchema,
input: "image", input: "image",
run: imgTool((img, p) => strokeImage(img, p.thickness, p.color)), 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", 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, schema: findContourSchema,
input: "image", input: "image",
run: imgTool((img, p) => contourImage(img, p.thickness, p.color)), 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", 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, schema: removeColorSchema,
input: "image", input: "image",
run: imgTool((img, p) => removeColorToAlpha(img, p.targetColor, p.tolerance)), 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", 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, schema: circleMaskSchema,
input: "image", input: "image",
run: imgTool((img, p) => run: imgTool((img, p) =>
@@ -207,13 +187,8 @@ const squareMaskSchema = toolSchema<SquareMaskParams>(
}, },
); );
const squareMask: ToolEntry<SquareMaskParams> = { const squareMask: Tool<SquareMaskParams> = {
id: "square-mask-png", 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, schema: squareMaskSchema,
input: "image", input: "image",
run: imgTool((img, p) => run: imgTool((img, p) =>
@@ -286,13 +261,8 @@ const starMaskSchema = toolSchema<StarMaskParams>(
}, },
); );
const starMask: ToolEntry<StarMaskParams> = { const starMask: Tool<StarMaskParams> = {
id: "star-mask-png", 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, schema: starMaskSchema,
input: "image", input: "image",
run: imgTool((img, p) => run: imgTool((img, p) =>
@@ -365,13 +335,8 @@ const wavyMaskSchema = toolSchema<WavyMaskParams>(
}, },
); );
const wavyMask: ToolEntry<WavyMaskParams> = { const wavyMask: Tool<WavyMaskParams> = {
id: "wavy-mask-png", 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, schema: wavyMaskSchema,
input: "image", input: "image",
run: imgTool((img, p) => run: imgTool((img, p) =>
@@ -388,13 +353,8 @@ interface EmptyParams {}
export const removeAlphaChannelSchema = toolSchema<EmptyParams>({}); export const removeAlphaChannelSchema = toolSchema<EmptyParams>({});
const removeAlphaChannel: ToolEntry<EmptyParams> = { const removeAlphaChannel: Tool<EmptyParams> = {
id: "remove-alpha-channel-png", 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, schema: removeAlphaChannelSchema,
input: "image", input: "image",
run: imgTool((img) => flattenOntoColor(img, "#ffffff")), 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", 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, schema: setAlphaChannelSchema,
input: "image", input: "image",
run: imgTool((img, p) => setAlphaChannel(img, p.percent)), run: imgTool((img, p) => setAlphaChannel(img, p.percent)),
@@ -427,12 +383,8 @@ const setAlphaChannelTool: ToolEntry<SetAlphaChannelParams> = {
export const extractAlphaMaskSchema = toolSchema<EmptyParams>({}); export const extractAlphaMaskSchema = toolSchema<EmptyParams>({});
const extractAlphaMaskTool: ToolEntry<EmptyParams> = { const extractAlphaMaskTool: Tool<EmptyParams> = {
id: "extract-alpha-mask-png", 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, schema: extractAlphaMaskSchema,
input: "image", input: "image",
run: imgTool((img) => extractAlphaMask(img)), 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", 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, schema: roundCornersSchema,
input: "image", input: "image",
run: imgTool((img, p) => roundCorners(img, p.radius)), run: imgTool((img, p) => roundCorners(img, p.radius)),
@@ -466,12 +413,8 @@ const roundCornersTool: ToolEntry<RoundCornersParams> = {
export const invertAlphaSchema = toolSchema<EmptyParams>({}); export const invertAlphaSchema = toolSchema<EmptyParams>({});
const invertAlphaTool: ToolEntry<EmptyParams> = { const invertAlphaTool: Tool<EmptyParams> = {
id: "invert-alpha-png", id: "invert-alpha-png",
slug: "invert-alpha-png",
title: "Invert alpha PNG",
description: "Opaque areas become transparent and vice versa.",
category: "alpha",
schema: invertAlphaSchema, schema: invertAlphaSchema,
input: "image", input: "image",
run: imgTool((img) => invertAlpha(img)), 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", 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, schema: removeBackgroundSchema,
input: "image", input: "image",
run: imgTool((img, p) => 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", 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, schema: makeThickerSchema,
input: "image", input: "image",
run: imgTool((img, p) => dilateImage(img, p.radius)), 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", 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, schema: makeThinnerSchema,
input: "image", input: "image",
run: imgTool((img, p) => erodeImage(img, p.radius)), 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", 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, schema: featherEdgesSchema,
input: "image", input: "image",
run: imgTool((img, p) => featherAlpha(img, p.radius)), 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", 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, schema: cleanEdgesSchema,
input: "image", input: "image",
run: imgTool((img, p) => defringe(img, p.radius)), 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", 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, schema: hardenAlphaSchema,
input: "image", input: "image",
run: imgTool((img, p) => hardenAlpha(img, p.threshold)), 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", 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, schema: despeckleAlphaSchema,
input: "image", input: "image",
run: imgTool((img, p) => openingImage(img, p.radius)), 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", 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, schema: closeHolesSchema,
input: "image", input: "image",
run: imgTool((img, p) => closingImage(img, p.radius)), run: imgTool((img, p) => closingImage(img, p.radius)),
}; };
export const alphaEntries = [ export const alphaTools = [
addStroke, addStroke,
findContour, findContour,
removeColor, removeColor,
@@ -1,15 +1,19 @@
import { hasTransparency, isGrayscale, orientationOf } from "../core/analyze"; import {
import { encode } from "../core/io"; hasTransparency,
isGrayscale,
orientationOf,
} from "../../core/analyze";
import { encode } from "../../core/io";
import { import {
extractByColor, extractByColor,
isGrayscaleish, isGrayscaleish,
luma01, luma01,
rarityPredicate, rarityPredicate,
renderPredicateMask, renderPredicateMask,
} from "../core/masks"; } from "../../core/masks";
import { base64ToBytes, looksLikePng, stripDataUri } from "../core/textio"; import { base64ToBytes, looksLikePng, stripDataUri } from "../../core/textio";
import { field, toolSchema } from "../registry-schema"; import { field, toolSchema } from "../../registry-schema";
import { imgTool, textGen, type ToolEntry } from "./types"; import { imgTool, textGen, type Tool } from "../types";
interface ExtractColorParams { interface ExtractColorParams {
color: string; color: string;
@@ -27,13 +31,8 @@ export const extractColorSchema = toolSchema<ExtractColorParams>({
}), }),
}); });
const extractColor: ToolEntry<ExtractColorParams> = { const extractColor: Tool<ExtractColorParams> = {
id: "extract-color-from-png", 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, schema: extractColorSchema,
input: "image", input: "image",
run: imgTool((img, p) => extractByColor(img, p.color, p.tolerance)), 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", 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, schema: showTransparentSchema,
input: "image", input: "image",
run: imgTool((img, p) => renderMask(img, p, (_r, _g, _b, a) => a < 255)), 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", 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, schema: showGrayscalePixelsSchema,
input: "image", input: "image",
run: imgTool((img, p) => 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", 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, schema: showColorPixelsSchema,
input: "image", input: "image",
run: imgTool((img, p) => 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", 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, schema: lightPixelMaskSchema,
input: "image", input: "image",
run: imgTool((img, p) => 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", 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, schema: darkPixelMaskSchema,
input: "image", input: "image",
run: imgTool((img, p) => 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", 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, schema: uniqueColorMaskSchema,
input: "image", input: "image",
run: imgTool((img, p) => renderMask(img, p, rarityPredicate(img, p.rarity))), run: imgTool((img, p) => renderMask(img, p, rarityPredicate(img, p.rarity))),
@@ -245,13 +216,8 @@ interface NoParams {}
const emptySchema = toolSchema<NoParams>({}); const emptySchema = toolSchema<NoParams>({});
const verifyIsPng: ToolEntry<NoParams> = { const verifyIsPng: Tool<NoParams> = {
id: "verify-is-png", 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, schema: emptySchema,
input: "text", input: "text",
result: "verdict", result: "verdict",
@@ -260,24 +226,16 @@ const verifyIsPng: ToolEntry<NoParams> = {
), ),
}; };
const pngIsGrayscale: ToolEntry<NoParams> = { const pngIsGrayscale: Tool<NoParams> = {
id: "png-is-grayscale", 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, schema: emptySchema,
input: "image", input: "image",
result: "verdict", result: "verdict",
run: imgTool((img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo")), run: imgTool((img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo")),
}; };
const pngFileSize: ToolEntry<NoParams> = { const pngFileSize: Tool<NoParams> = {
id: "png-file-size", 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, schema: emptySchema,
domOnly: true, domOnly: true,
input: "image", input: "image",
@@ -290,13 +248,8 @@ const pngFileSize: ToolEntry<NoParams> = {
}), }),
}; };
const pngIsTransparent: ToolEntry<NoParams> = { const pngIsTransparent: Tool<NoParams> = {
id: "png-is-transparent", 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, schema: emptySchema,
input: "image", input: "image",
result: "verdict", result: "verdict",
@@ -305,12 +258,8 @@ const pngIsTransparent: ToolEntry<NoParams> = {
), ),
}; };
const pngOrientation: ToolEntry<NoParams> = { const pngOrientation: Tool<NoParams> = {
id: "png-orientation", id: "png-orientation",
slug: "png-orientation",
title: "PNG orientation",
description: "Reports whether it is portrait, landscape or square.",
category: "analyze",
schema: emptySchema, schema: emptySchema,
input: "image", input: "image",
result: "verdict", result: "verdict",
@@ -326,7 +275,7 @@ const pngOrientation: ToolEntry<NoParams> = {
}), }),
}; };
export const analyzeEntries = [ export const analyzeTools = [
extractColor, extractColor,
showTransparent, showTransparent,
showGrayscalePixels, showGrayscalePixels,
@@ -16,12 +16,12 @@ import {
twoColors, twoColors,
type ChannelSwapPair, type ChannelSwapPair,
type RgbChannel, type RgbChannel,
} from "../core/color"; } from "../../core/color";
import { renderSpace, SPACES, type SpaceId } from "../core/channels"; import { renderSpace, SPACES, type SpaceId } from "../../core/channels";
import { parseHexList } from "../core/palette"; import { parseHexList } from "../../core/palette";
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize"; import { ditherImage, mapToNearest, quantizeImage } from "../../core/quantize";
import { field, toolSchema, type ColorPair } from "../registry-schema"; import { field, toolSchema, type ColorPair } from "../../registry-schema";
import { imgTool, type ToolEntry } from "./types"; import { imgTool, type Tool } from "../types";
interface TwoColorsParams { interface TwoColorsParams {
pair: ColorPair; pair: ColorPair;
@@ -43,13 +43,8 @@ export const twoColorsSchema = toolSchema<TwoColorsParams>({
}), }),
}); });
const twoColorsTool: ToolEntry<TwoColorsParams> = { const twoColorsTool: Tool<TwoColorsParams> = {
id: "two-colors-png", 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, schema: twoColorsSchema,
input: "image", input: "image",
run: imgTool((img, p) => twoColors(img, p.pair.from, p.pair.to, p.threshold)), 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", id: "gamma-png",
slug: "gamma-png",
title: "Gamma correction PNG",
description:
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
category: "color",
schema: gammaSchema, schema: gammaSchema,
input: "image", input: "image",
run: imgTool((img, p) => gammaCorrection(img, p.value)), 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", 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, schema: temperatureSchema,
input: "image", input: "image",
run: imgTool((img, p) => temperature(img, p.percent)), 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", 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, schema: tintSchema,
input: "image", input: "image",
run: imgTool((img, p) => tint(img, p.color, p.strength)), 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", 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, schema: quantizeSchema,
input: "image", input: "image",
run: imgTool((img, p) => quantizeImage(img, p.colors).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" }), colors: field.text({ label: "fields.hexList", default: "#000000,#ffffff" }),
}); });
const customPalette: ToolEntry<CustomPaletteParams> = { const customPalette: Tool<CustomPaletteParams> = {
id: "custom-palette-png", 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, schema: customPaletteSchema,
input: "image", input: "image",
run: imgTool((img, p) => mapToNearest(img, parseHexList(p.colors))), 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", 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, schema: ditheringSchema,
input: "image", input: "image",
run: imgTool((img, p) => ditherImage(img, p.colors, p.pattern)), run: imgTool((img, p) => ditherImage(img, p.colors, p.pattern)),
@@ -220,13 +185,8 @@ interface EmptyParams {}
export const grayscaleSchema = toolSchema<EmptyParams>({}); export const grayscaleSchema = toolSchema<EmptyParams>({});
const grayscaleTool: ToolEntry<EmptyParams> = { const grayscaleTool: Tool<EmptyParams> = {
id: "grayscale-png", 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, schema: grayscaleSchema,
input: "image", input: "image",
run: imgTool((img) => grayscale(img)), run: imgTool((img) => grayscale(img)),
@@ -234,12 +194,8 @@ const grayscaleTool: ToolEntry<EmptyParams> = {
export const invertColorsSchema = toolSchema<EmptyParams>({}); export const invertColorsSchema = toolSchema<EmptyParams>({});
const invertColorsTool: ToolEntry<EmptyParams> = { const invertColorsTool: Tool<EmptyParams> = {
id: "invert-colors-png", 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, schema: invertColorsSchema,
input: "image", input: "image",
run: imgTool((img) => invert(img)), 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", 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, schema: brightnessContrastSchema,
input: "image", input: "image",
run: imgTool((img, p) => brightnessContrast(img, p.brightness, p.contrast)), 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", 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, schema: opacitySchema,
input: "image", input: "image",
run: imgTool((img, p) => setOpacity(img, p.percent)), run: imgTool((img, p) => setOpacity(img, p.percent)),
@@ -316,12 +262,8 @@ const opacityTool: ToolEntry<OpacityParams> = {
export const sepiaSchema = toolSchema<EmptyParams>({}); export const sepiaSchema = toolSchema<EmptyParams>({});
const sepiaTool: ToolEntry<EmptyParams> = { const sepiaTool: Tool<EmptyParams> = {
id: "sepia-png", 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, schema: sepiaSchema,
input: "image", input: "image",
run: imgTool((img) => sepia(img)), 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", 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, schema: hueShiftSchema,
input: "image", input: "image",
run: imgTool((img, p) => changeHue(img, p.degrees)), 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", 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, schema: extractChannelSchema,
input: "image", input: "image",
run: imgTool((img, p) => extractChannel(img, p.channel)), 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", 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, schema: swapChannelsSchema,
input: "image", input: "image",
run: imgTool((img, p) => swapChannels(img, p.pair)), 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", 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, schema: blackAndWhiteSchema,
input: "image", input: "image",
run: imgTool((img, p) => thresholdBlackWhite(img, p.threshold)), 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", id: "posterize-png",
slug: "posterize-png",
title: "Posterize PNG",
description: "Reduces the number of levels per channel — a poster effect.",
category: "color",
schema: posterizeSchema, schema: posterizeSchema,
input: "image", input: "image",
run: imgTool((img, p) => posterize(img, p.levels)), run: imgTool((img, p) => posterize(img, p.levels)),
@@ -462,13 +380,8 @@ const posterizeTool: ToolEntry<PosterizeParams> = {
export const autoContrastSchema = toolSchema<EmptyParams>({}); export const autoContrastSchema = toolSchema<EmptyParams>({});
const autoContrastTool: ToolEntry<EmptyParams> = { const autoContrastTool: Tool<EmptyParams> = {
id: "auto-contrast-png", 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, schema: autoContrastSchema,
input: "image", input: "image",
run: imgTool((img) => autoContrast(img)), 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", 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, schema: decreaseColorCountSchema,
input: "image", input: "image",
run: imgTool((img, p) => quantizeImage(img, Number(p.maxColors)).image), run: imgTool((img, p) => quantizeImage(img, Number(p.maxColors)).image),
@@ -516,60 +424,15 @@ interface ChannelParams {
display: "gray" | "color"; display: "gray" | "color";
} }
type SpaceEntry = { // Пространства, для которых есть инструмент и страница; тексты страницы живут
id: SpaceId; // в `registry/pages/color.ts`, поэтому здесь только идентификаторы.
title: string; const CHANNEL_SPACES: SpaceId[] = ["hsl", "hsv", "hsi", "cmyk", "ycbcr", "lab"];
description: string;
};
const CHANNEL_SPACES: SpaceEntry[] = [ function channelEntries(): Tool<ChannelParams>[] {
{
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>[] {
return CHANNEL_SPACES.map((space) => { return CHANNEL_SPACES.map((space) => {
const components = SPACES[space.id].components; const components = SPACES[space].components;
return { return {
id: `png-to-${space.id}`, id: `png-to-${space}`,
slug: `png-to-${space.id}`,
title: space.title,
description: space.description,
category: "color",
schema: toolSchema<ChannelParams>({ schema: toolSchema<ChannelParams>({
component: field.select({ component: field.select({
label: "fields.component", label: "fields.component",
@@ -589,14 +452,12 @@ function channelEntries(): ToolEntry<ChannelParams>[] {
}), }),
}), }),
input: "image", input: "image",
run: imgTool((img, p) => run: imgTool((img, p) => renderSpace(img, space, p.component, p.display)),
renderSpace(img, space.id, p.component, p.display), } satisfies Tool<ChannelParams>;
),
} satisfies ToolEntry<ChannelParams>;
}); });
} }
export const colorEntries = [ export const colorTools = [
twoColorsTool, twoColorsTool,
gammaTool, gammaTool,
temperatureTool, temperatureTool,
@@ -1,6 +1,6 @@
import { flattenOntoColor } from "../core/alpha"; import { flattenOntoColor } from "../../core/alpha";
import { decodeBytes, decodeSvgText, toBase64, toDataUrl } from "../core/io"; import { decodeBytes, decodeSvgText, toBase64, toDataUrl } from "../../core/io";
import { hexToPixels, pixelsToHex } from "../core/text"; import { hexToPixels, pixelsToHex } from "../../core/text";
import { import {
base64ToBytes, base64ToBytes,
bytesToImage, bytesToImage,
@@ -8,10 +8,10 @@ import {
imageToRgbValues, imageToRgbValues,
rgbValuesToImage, rgbValuesToImage,
stripDataUri, stripDataUri,
} from "../core/textio"; } from "../../core/textio";
import { clonePixelImage } from "../core/types"; import { clonePixelImage } from "../../core/types";
import { field, toolSchema } from "../registry-schema"; import { field, toolSchema } from "../../registry-schema";
import { imgTool, textGen, type ToolEntry } from "./types"; import { imgTool, textGen, type Tool } from "../types";
const widthProps = (defaultValue: number) => const widthProps = (defaultValue: number) =>
field.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", 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, schema: convertToJpgSchema,
input: "image", input: "image",
run: imgTool((img, p) => flattenOntoColor(img, p.background)), 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", 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, schema: convertToWebpSchema,
input: "image", input: "image",
run: imgTool((img) => clonePixelImage(img)), run: imgTool((img) => clonePixelImage(img)),
@@ -82,13 +72,8 @@ interface ConvertToBmpParams {}
export const convertToBmpSchema = toolSchema<ConvertToBmpParams>({}); export const convertToBmpSchema = toolSchema<ConvertToBmpParams>({});
const convertToBmp: ToolEntry<ConvertToBmpParams> = { const convertToBmp: Tool<ConvertToBmpParams> = {
id: "png-to-bmp", 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, schema: convertToBmpSchema,
input: "image", input: "image",
run: imgTool((img) => flattenOntoColor(img, "#000000")), run: imgTool((img) => flattenOntoColor(img, "#000000")),
@@ -99,13 +84,8 @@ interface NoParams {}
const emptySchema = toolSchema<NoParams>({}); const emptySchema = toolSchema<NoParams>({});
const pngToBase64: ToolEntry<NoParams> = { const pngToBase64: Tool<NoParams> = {
id: "png-to-base64", 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, schema: emptySchema,
input: "image", input: "image",
result: "text", result: "text",
@@ -113,13 +93,8 @@ const pngToBase64: ToolEntry<NoParams> = {
run: imgTool((img) => toBase64(img)), run: imgTool((img) => toBase64(img)),
}; };
const pngToDataUri: ToolEntry<NoParams> = { const pngToDataUri: Tool<NoParams> = {
id: "png-to-data-uri", 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, schema: emptySchema,
input: "image", input: "image",
result: "text", result: "text",
@@ -127,63 +102,40 @@ const pngToDataUri: ToolEntry<NoParams> = {
run: imgTool((img) => toDataUrl(img)), run: imgTool((img) => toDataUrl(img)),
}; };
const pngToHex: ToolEntry<NoParams> = { const pngToHex: Tool<NoParams> = {
id: "png-to-hex", 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, schema: emptySchema,
input: "image", input: "image",
result: "text", result: "text",
run: imgTool((img) => pixelsToHex(img)), run: imgTool((img) => pixelsToHex(img)),
}; };
const pngToBytes: ToolEntry<NoParams> = { const pngToBytes: Tool<NoParams> = {
id: "png-to-bytes", 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, schema: emptySchema,
input: "image", input: "image",
result: "text", result: "text",
run: imgTool((img) => imageToByteRows(img)), run: imgTool((img) => imageToByteRows(img)),
}; };
const pngToRgbValues: ToolEntry<NoParams> = { const pngToRgbValues: Tool<NoParams> = {
id: "png-to-rgb-values", 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, schema: emptySchema,
input: "image", input: "image",
result: "text", result: "text",
run: imgTool((img) => imageToRgbValues(img)), run: imgTool((img) => imageToRgbValues(img)),
}; };
const base64ToPng: ToolEntry<NoParams> = { const base64ToPng: Tool<NoParams> = {
id: "base64-to-png", 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, schema: emptySchema,
input: "text", input: "text",
domOnly: true, domOnly: true,
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))), run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
}; };
const dataUriToPng: ToolEntry<NoParams> = { const dataUriToPng: Tool<NoParams> = {
id: "data-uri-to-png", 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, schema: emptySchema,
input: "text", input: "text",
domOnly: true, domOnly: true,
@@ -198,13 +150,8 @@ export const hexToPngSchema = toolSchema<HexToPngParams>({
width: widthProps(1), width: widthProps(1),
}); });
const hexToPng: ToolEntry<HexToPngParams> = { const hexToPng: Tool<HexToPngParams> = {
id: "hex-to-png", 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, schema: hexToPngSchema,
input: "text", input: "text",
run: textGen((text, p) => hexToPixels(text, Math.trunc(p.width))), run: textGen((text, p) => hexToPixels(text, Math.trunc(p.width))),
@@ -218,13 +165,8 @@ export const bytesToPngSchema = toolSchema<BytesToPngParams>({
width: widthProps(32), width: widthProps(32),
}); });
const bytesToPng: ToolEntry<BytesToPngParams> = { const bytesToPng: Tool<BytesToPngParams> = {
id: "bytes-to-png", 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, schema: bytesToPngSchema,
input: "text", input: "text",
run: textGen((text, p) => bytesToImage(text, Math.trunc(p.width))), run: textGen((text, p) => bytesToImage(text, Math.trunc(p.width))),
@@ -238,13 +180,8 @@ export const rgbValuesToPngSchema = toolSchema<RgbValuesToPngParams>({
width: widthProps(32), width: widthProps(32),
}); });
const rgbValuesToPng: ToolEntry<RgbValuesToPngParams> = { const rgbValuesToPng: Tool<RgbValuesToPngParams> = {
id: "rgb-values-to-png", 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, schema: rgbValuesToPngSchema,
input: "text", input: "text",
run: textGen((text, p) => rgbValuesToImage(text, Math.trunc(p.width))), run: textGen((text, p) => rgbValuesToImage(text, Math.trunc(p.width))),
@@ -258,20 +195,15 @@ export const svgToPngSchema = toolSchema<SvgToPngParams>({
width: widthProps(512), width: widthProps(512),
}); });
const svgToPng: ToolEntry<SvgToPngParams> = { const svgToPng: Tool<SvgToPngParams> = {
id: "svg-to-png", 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, schema: svgToPngSchema,
input: "text", input: "text",
domOnly: true, domOnly: true,
run: textGen((text, p) => decodeSvgText(text, Math.trunc(p.width))), run: textGen((text, p) => decodeSvgText(text, Math.trunc(p.width))),
}; };
export const convertEntries = [ export const convertTools = [
convertToJpg, convertToJpg,
convertToWebp, convertToWebp,
convertToBmp, convertToBmp,
@@ -1,14 +1,14 @@
import { gaussianBlur, sharpen as sharpenImage } from "../core/convolution"; import { gaussianBlur, sharpen as sharpenImage } from "../../core/convolution";
import { vignette } from "../core/effects"; import { vignette } from "../../core/effects";
import { jpegRoundtrip } from "../core/io"; import { jpegRoundtrip } from "../../core/io";
import { import {
addNoise, addNoise,
pixelate, pixelate,
shuffleBlocks, shuffleBlocks,
silhouette, silhouette,
} from "../core/pixel-fx"; } from "../../core/pixel-fx";
import { field, toolSchema } from "../registry-schema"; import { field, toolSchema } from "../../registry-schema";
import { imgTool, type ToolEntry } from "./types"; import { imgTool, type Tool } from "../types";
interface BlurParams { interface BlurParams {
radius: number; radius: number;
@@ -24,13 +24,8 @@ export const blurSchema = toolSchema<BlurParams>({
}), }),
}); });
const blurTool: ToolEntry<BlurParams> = { const blurTool: Tool<BlurParams> = {
id: "blur-png", 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, schema: blurSchema,
input: "image", input: "image",
run: imgTool((img, p) => gaussianBlur(img, p.radius)), 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", 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, schema: sharpenSchema,
input: "image", input: "image",
run: imgTool((img, p) => sharpenImage(img, p.strength)), 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", 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, schema: silhouetteSchema,
input: "image", input: "image",
run: imgTool((img, p) => silhouette(img, p.color, p.threshold * 2.55)), 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", 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, schema: vignetteSchema,
input: "image", input: "image",
run: imgTool((img, p) => vignette(img, p.strength)), 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", id: "pixelate-png",
slug: "pixelate-png",
title: "Pixelate PNG",
description:
"Averages every blockSize×blockSize area into one color — classic mosaic.",
category: "filters",
schema: pixelateSchema, schema: pixelateSchema,
input: "image", input: "image",
run: imgTool((img, p) => pixelate(img, p.blockSize)), 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", 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, schema: randomizePixelsSchema,
input: "image", input: "image",
run: imgTool((img, p) => shuffleBlocks(img, p.blockSize, p.seed)), 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", 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, schema: addNoiseSchema,
input: "image", input: "image",
run: imgTool((img, p) => addNoise(img, p.amount, p.mode, p.seed)), 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", 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, schema: jpegArtifactsSchema,
input: "image", input: "image",
domOnly: true, domOnly: true,
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)), run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
}; };
export const filtersEntries = [ export const filtersTools = [
vignetteTool, vignetteTool,
pixelateTool, pixelateTool,
randomizePixels, randomizePixels,
@@ -1,7 +1,11 @@
import { renderEmoji, renderTextToImage } from "../core/domText"; import { renderEmoji, renderTextToImage } from "../../core/domText";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools"; import {
import { noiseImage, solidImage } from "../core/generate"; colorSpectrum,
import { changeCanvasSize } from "../core/geometry"; drawGrid,
randomColorBlocks,
} from "../../core/gen-tools";
import { noiseImage, solidImage } from "../../core/generate";
import { changeCanvasSize } from "../../core/geometry";
import { import {
analogousSet, analogousSet,
complementarySet, complementarySet,
@@ -16,8 +20,8 @@ import {
stepColors, stepColors,
tetradicSet, tetradicSet,
triadicSet, triadicSet,
} from "../core/palette"; } from "../../core/palette";
import type { PixelImage } from "../core/types"; import type { PixelImage } from "../../core/types";
import { import {
field, field,
toolSchema, toolSchema,
@@ -25,8 +29,8 @@ import {
type Dimension, type Dimension,
type FontStyle, type FontStyle,
type Gradient, type Gradient,
} from "../registry-schema"; } from "../../registry-schema";
import { genTool, type ToolEntry } from "./types"; import { genTool, type Tool } from "../types";
function rgba(hex: string): [number, number, number, number] { function rgba(hex: string): [number, number, number, number] {
const { r, g, b } = hexToRgb(hex); 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", 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, schema: createEmptySchema,
input: "none", input: "none",
run: genTool((p) => { run: genTool((p) => {
@@ -135,12 +134,8 @@ export const singleColorSchema = toolSchema<SingleColorParams>({
color: field.color({ label: "fields.color", default: "#ff0000" }), color: field.color({ label: "fields.color", default: "#ff0000" }),
}); });
const singleColor: ToolEntry<SingleColorParams> = { const singleColor: Tool<SingleColorParams> = {
id: "single-color-png", 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, schema: singleColorSchema,
input: "none", input: "none",
run: genTool((p) => { run: genTool((p) => {
@@ -172,13 +167,8 @@ export const randomNoiseSchema = toolSchema<RandomNoiseParams>({
}), }),
}); });
const randomNoise: ToolEntry<RandomNoiseParams> = { const randomNoise: Tool<RandomNoiseParams> = {
id: "random-noise-png", 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, schema: randomNoiseSchema,
input: "none", input: "none",
run: genTool((p) => { run: genTool((p) => {
@@ -219,13 +209,8 @@ export const linearGradientSchema = toolSchema<LinearGradientParams>(
}, },
); );
const linearGradient: ToolEntry<LinearGradientParams> = { const linearGradient: Tool<LinearGradientParams> = {
id: "linear-gradient-png", 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, schema: linearGradientSchema,
input: "none", input: "none",
run: genTool((p) => { run: genTool((p) => {
@@ -293,13 +278,8 @@ export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>(
}, },
); );
const colorSpectrumTool: ToolEntry<ColorSpectrumParams> = { const colorSpectrumTool: Tool<ColorSpectrumParams> = {
id: "color-spectrum-png", 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, schema: colorSpectrumSchema,
input: "none", input: "none",
run: genTool((p) => { run: genTool((p) => {
@@ -349,13 +329,8 @@ export const randomColorsSchema = toolSchema<RandomColorsParams>(
}, },
); );
const randomColors: ToolEntry<RandomColorsParams> = { const randomColors: Tool<RandomColorsParams> = {
id: "random-colors-png", 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, schema: randomColorsSchema,
input: "none", input: "none",
run: genTool((p) => { run: genTool((p) => {
@@ -424,13 +399,8 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
}, },
); );
const drawGridTool: ToolEntry<DrawGridParams> = { const drawGridTool: Tool<DrawGridParams> = {
id: "draw-grid-png", 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, schema: drawGridSchema,
input: "none", input: "none",
run: genTool((p) => { run: genTool((p) => {
@@ -486,13 +456,8 @@ export const placeholderSchema = toolSchema<PlaceholderParams>(
}, },
); );
const placeholder: ToolEntry<PlaceholderParams> = { const placeholder: Tool<PlaceholderParams> = {
id: "placeholder-png", 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, schema: placeholderSchema,
input: "none", input: "none",
domOnly: true, domOnly: true,
@@ -537,12 +502,8 @@ export const blendTwoSchema = toolSchema<BlendTwoParams>({
}), }),
}); });
const blendTwo: ToolEntry<BlendTwoParams> = { const blendTwo: Tool<BlendTwoParams> = {
id: "blend-two-png", 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, schema: blendTwoSchema,
input: "none", input: "none",
run: genTool((p) => renderBlend(p.pair.from, p.pair.to, p.width)), 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", 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, schema: stepColorsSchema,
input: "none", input: "none",
run: genTool((p) => run: genTool((p) =>
@@ -632,13 +589,8 @@ export const emojiToPngSchema = toolSchema<EmojiToPngParams>({
}), }),
}); });
const emojiToPng: ToolEntry<EmojiToPngParams> = { const emojiToPng: Tool<EmojiToPngParams> = {
id: "emoji-to-png", 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, domOnly: true,
schema: emojiToPngSchema, schema: emojiToPngSchema,
input: "none", input: "none",
@@ -667,13 +619,8 @@ export const colorWheelSchema = toolSchema<ColorWheelParams>({
}), }),
}); });
const colorWheelTool: ToolEntry<ColorWheelParams> = { const colorWheelTool: Tool<ColorWheelParams> = {
id: "color-wheel-png", 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, schema: colorWheelSchema,
input: "none", input: "none",
run: genTool((p) => renderWheel(p.size, p.lightness)), 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", 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>( schema: toolSchema<PaletteBaseParams>(
{ ...paletteBaseSchema }, { ...paletteBaseSchema },
{ ...paletteLayoutGroup }, { ...paletteLayoutGroup },
@@ -734,12 +676,8 @@ const complementaryTool: ToolEntry<PaletteBaseParams> = {
), ),
}; };
const triadicTool: ToolEntry<PaletteBaseParams> = { const triadicTool: Tool<PaletteBaseParams> = {
id: "triadic-png", 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>( schema: toolSchema<PaletteBaseParams>(
{ {
...paletteBaseSchema, ...paletteBaseSchema,
@@ -753,13 +691,8 @@ const triadicTool: ToolEntry<PaletteBaseParams> = {
), ),
}; };
const tetradicTool: ToolEntry<PaletteBaseParams> = { const tetradicTool: Tool<PaletteBaseParams> = {
id: "tetradic-png", 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>( schema: toolSchema<PaletteBaseParams>(
{ {
...paletteBaseSchema, ...paletteBaseSchema,
@@ -778,13 +711,8 @@ interface AnalogousParams extends PaletteBaseParams {
count: number; count: number;
} }
const analogousTool: ToolEntry<AnalogousParams> = { const analogousTool: Tool<AnalogousParams> = {
id: "analogous-png", 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>( schema: toolSchema<AnalogousParams>(
{ {
...paletteBaseSchema, ...paletteBaseSchema,
@@ -821,13 +749,8 @@ interface MonochromaticParams extends PaletteBaseParams {
range: number; range: number;
} }
const monochromaticTool: ToolEntry<MonochromaticParams> = { const monochromaticTool: Tool<MonochromaticParams> = {
id: "monochromatic-png", 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>( schema: toolSchema<MonochromaticParams>(
{ {
...paletteBaseSchema, ...paletteBaseSchema,
@@ -864,12 +787,8 @@ interface ShadesParams extends PaletteBaseParams {
depth: number; depth: number;
} }
const shadesTool: ToolEntry<ShadesParams> = { const shadesTool: Tool<ShadesParams> = {
id: "shades-png", 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>( schema: toolSchema<ShadesParams>(
{ {
...paletteBaseSchema, ...paletteBaseSchema,
@@ -926,13 +845,8 @@ export const mixColorsSchema = toolSchema<MixColorsParams>(
}, },
); );
const mixColorsTool: ToolEntry<MixColorsParams> = { const mixColorsTool: Tool<MixColorsParams> = {
id: "mix-colors-png", 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, schema: mixColorsSchema,
input: "none", input: "none",
run: genTool((p) => renderSwatches([mixColors(p.colors)], p.width, "strip")), 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", 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, schema: sortColorsSchema,
input: "none", input: "none",
run: genTool((p) => run: genTool((p) =>
@@ -1065,13 +974,8 @@ export const textToPngSchema = toolSchema<TextToPngParams>(
}, },
); );
const textToPng: ToolEntry<TextToPngParams> = { const textToPng: Tool<TextToPngParams> = {
id: "text-to-png", 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, domOnly: true,
schema: textToPngSchema, schema: textToPngSchema,
input: "none", input: "none",
@@ -1089,7 +993,7 @@ const textToPng: ToolEntry<TextToPngParams> = {
), ),
}; };
export const generateEntries = [ export const generateTools = [
createEmpty, createEmpty,
singleColor, singleColor,
randomNoise, randomNoise,
@@ -3,8 +3,8 @@ import {
skewImage, skewImage,
transformImage, transformImage,
zoomImage, zoomImage,
} from "../core/affine"; } from "../../core/affine";
import { ToolError } from "../core/errors"; import { ToolError } from "../../core/errors";
import { import {
centerByAlpha, centerByAlpha,
changeCanvasSize, changeCanvasSize,
@@ -22,14 +22,14 @@ import {
trimToContent, trimToContent,
type Anchor9, type Anchor9,
type FlipAxis, type FlipAxis,
} from "../core/geometry"; } from "../../core/geometry";
import { field, toolSchema, type Dimension } from "../registry-schema"; import { field, toolSchema, type Dimension } from "../../registry-schema";
import { import {
imgTool, imgTool,
requireSource, requireSource,
type ToolEntry, type Tool,
type ToolImageFile, type ToolImageFile,
} from "./types"; } from "../types";
interface AddBorderParams { interface AddBorderParams {
thickness: number; thickness: number;
@@ -47,13 +47,8 @@ export const addBorderSchema = toolSchema<AddBorderParams>({
color: field.color({ label: "fields.borderColor", default: "#000000" }), color: field.color({ label: "fields.borderColor", default: "#000000" }),
}); });
const addBorder: ToolEntry<AddBorderParams> = { const addBorder: Tool<AddBorderParams> = {
id: "add-border-png", 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, schema: addBorderSchema,
input: "image", input: "image",
run: imgTool((img, p) => 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", 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, schema: fitOnBackgroundSchema,
input: "image", input: "image",
run: imgTool((img, p) => { 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", 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, schema: changeCanvasSizeSchema,
input: "image", input: "image",
run: imgTool((img, p) => run: imgTool((img, p) =>
@@ -213,13 +198,8 @@ export const resizeSchema = toolSchema<ResizeParams>(
}, },
); );
const resizeTool: ToolEntry<ResizeParams> = { const resizeTool: Tool<ResizeParams> = {
id: "resize-png", 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, schema: resizeSchema,
input: "image", input: "image",
run: imgTool((img, p) => { run: imgTool((img, p) => {
@@ -285,13 +265,8 @@ export const cropSchema = toolSchema<CropParams>(
}, },
); );
const cropTool: ToolEntry<CropParams> = { const cropTool: Tool<CropParams> = {
id: "crop-png", 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, schema: cropSchema,
input: "image", input: "image",
run: imgTool((img, p) => { run: imgTool((img, p) => {
@@ -320,12 +295,8 @@ export const rotateSchema = toolSchema<RotateParams>({
}), }),
}); });
const rotateTool: ToolEntry<RotateParams> = { const rotateTool: Tool<RotateParams> = {
id: "rotate-png", id: "rotate-png",
slug: "rotate-png",
title: "Rotate PNG",
description: "Rotates by 90°, 180° or 270° clockwise without quality loss.",
category: "geometry",
schema: rotateSchema, schema: rotateSchema,
input: "image", input: "image",
run: imgTool((img, p) => rotate90(img, Number(p.angle) / 90)), 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", id: "flip-png",
slug: "flip-png",
title: "Flip PNG",
description: "Mirrors horizontally or vertically without quality loss.",
category: "geometry",
schema: flipSchema, schema: flipSchema,
input: "image", input: "image",
run: imgTool((img, p) => flip(img, p.axis)), 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", 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, schema: addPaddingSchema,
input: "image", input: "image",
run: imgTool((img, p) => run: imgTool((img, p) =>
@@ -428,12 +390,8 @@ export const tileSchema = toolSchema<TileParams>({
}), }),
}); });
const tileTool: ToolEntry<TileParams> = { const tileTool: Tool<TileParams> = {
id: "tile-png", 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, schema: tileSchema,
input: "image", input: "image",
run: imgTool((img, p) => tile(img, p.columns, p.rows)), 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", 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, schema: splitPartsSchema,
input: "image", input: "image",
result: "files", result: "files",
@@ -501,13 +454,8 @@ interface EmptyParams {}
export const centerByAlphaSchema = toolSchema<EmptyParams>({}); export const centerByAlphaSchema = toolSchema<EmptyParams>({});
const centerByAlphaTool: ToolEntry<EmptyParams> = { const centerByAlphaTool: Tool<EmptyParams> = {
id: "center-by-alpha-png", 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, schema: centerByAlphaSchema,
input: "image", input: "image",
run: imgTool((img) => centerByAlpha(img)), 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", id: "skew-png",
slug: "skew-png",
title: "Skew PNG",
description:
"Shifts content horizontally and vertically — a perspective effect.",
category: "geometry",
schema: skewSchema, schema: skewSchema,
input: "image", input: "image",
run: imgTool((img, p) => skewImage(img, p.degX, p.degY)), 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", 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, schema: rotateFreeSchema,
input: "image", input: "image",
run: imgTool((img, p) => rotateFreeImage(img, p.angle)), 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", 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, schema: zoomSchema,
input: "image", input: "image",
run: imgTool((img, p) => zoomImage(img, p.scale)), 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", 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, schema: trimEmptySpaceSchema,
input: "image", input: "image",
run: imgTool((img, p) => trimToContent(img, p.threshold)), 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", 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, schema: changeAspectRatioSchema,
input: "image", input: "image",
run: imgTool((img, p) => { 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", 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, schema: swapOrientationSchema,
input: "image", input: "image",
run: imgTool((img, p) => forceOrientation(img, p.target)), 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", 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, schema: symmetricCopySchema,
input: "image", input: "image",
run: imgTool((img, p) => symmetricCopy(img, p.axis, p.keepSide)), 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" }), color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
}); });
const shiftTool: ToolEntry<ShiftParams> = { const shiftTool: Tool<ShiftParams> = {
id: "shift-png", id: "shift-png",
slug: "shift-png",
title: "Shift PNG",
description: "Moves content by the given X and Y offset.",
category: "geometry",
schema: shiftSchema, schema: shiftSchema,
input: "image", input: "image",
run: imgTool((img, p) => run: imgTool((img, p) =>
@@ -783,7 +692,7 @@ const shiftTool: ToolEntry<ShiftParams> = {
), ),
}; };
export const geometryEntries = [ export const geometryTools = [
addBorder, addBorder,
fitOnBackground, fitOnBackground,
changeCanvasSizeTool, changeCanvasSizeTool,
@@ -1,9 +1,9 @@
import { formatStamp } from "../core/datefmt"; import { formatStamp } from "../../core/datefmt";
import { drawTextBlock, drawTextTile } from "../core/domText"; import { drawTextBlock, drawTextTile } from "../../core/domText";
import type { Position9 } from "../core/textdraw"; import type { Position9 } from "../../core/textdraw";
import type { FontStyle, Plate } from "../registry-schema"; import type { FontStyle, Plate } from "../../registry-schema";
import { field, toolSchema } from "../registry-schema"; import { field, toolSchema } from "../../registry-schema";
import { imgTool, type ToolEntry } from "./types"; import { imgTool, type Tool } from "../types";
interface AddTextParams { interface AddTextParams {
text: string; text: string;
@@ -58,13 +58,8 @@ const addTextSchema = toolSchema<AddTextParams>(
}, },
); );
const addText: ToolEntry<AddTextParams> = { const addText: Tool<AddTextParams> = {
id: "add-text-png", 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, domOnly: true,
schema: addTextSchema, schema: addTextSchema,
input: "image", input: "image",
@@ -137,13 +132,8 @@ const dateStampSchema = toolSchema<DateStampParams>(
}, },
); );
const dateStamp: ToolEntry<DateStampParams> = { const dateStamp: Tool<DateStampParams> = {
id: "date-stamp-png", 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, domOnly: true,
schema: dateStampSchema, schema: dateStampSchema,
input: "image", input: "image",
@@ -231,13 +221,8 @@ const watermarkTileSchema = toolSchema<WatermarkTileParams>(
}, },
); );
const watermarkTile: ToolEntry<WatermarkTileParams> = { const watermarkTile: Tool<WatermarkTileParams> = {
id: "watermark-tile-png", 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, domOnly: true,
schema: watermarkTileSchema, schema: watermarkTileSchema,
input: "image", 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; export type ToolResult = PixelImage | string | FileResult | VerdictResult;
/** /**
* Инструмент нового registry: полностью типизирован на `Params`, схема — * Инструмент: уникальная реализация без адреса, текстов и категории — всё это
* обязательна (единый источник дефолтов/валидации/UI). У каждого инструмента * у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
* ровно один метод `run(ctx)`; контракт входа декларируется через `input` * одного инструмента на две страницы хватает, а страница из нескольких
* (обязательное поле), а результат — через `result`. Отдельных методов * инструментов — это уже цепочка. Схема обязательна (единый источник
* `generate`/`runFromText`/`toText`/`textToText` нет. * дефолтов/валидации/UI). У каждого инструмента ровно один метод `run(ctx)`;
* контракт входа декларируется через `input` (обязательное поле), а результат —
* через `result`. Отдельных методов `generate`/`runFromText`/`toText`/
* `textToText` нет.
*/ */
export type ToolEntry<P = Record<string, unknown>> = { export type Tool<P = Record<string, unknown>> = {
/** Внутреннее имя: ключ i18n `tools.<id>.*`, `toolId` worker-протокола, запись пайплайна. */ /** Внутреннее имя: ключ словаря инструмента, `toolId` worker-протокола, запись пайплайна. */
id: string; id: string;
/**
* Адрес страницы `/tools/<slug>` и база имени скачиваемого файла. Инструмент
* работает с любым форматом, поэтому png-интент держит slug, а не id.
*/
slug: string;
title: string;
description: string;
category: CategoryId;
schema: ToolSchema<P>; schema: ToolSchema<P>;
/** Чем управляется инструмент: входным изображением, текстом или ничем. */ /** Чем управляется инструмент: входным изображением, текстом или ничем. */
input: InputMode; input: InputMode;
@@ -91,7 +86,28 @@ export type ToolEntry<P = Record<string, unknown>> = {
domOnly?: boolean; domOnly?: boolean;
/** Формат/качество скачивания результата; по умолчанию — PNG. */ /** Формат/качество скачивания результата; по умолчанию — PNG. */
output?: OutputFormat; 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 Footer from "$lib/components/layout/Footer.svelte";
import TopBar from "$lib/components/layout/TopBar.svelte"; import TopBar from "$lib/components/layout/TopBar.svelte";
import { initLocale } from "$lib/i18n/locale.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 { t } from "$lib/i18n/t";
import { getTool } from "$lib/registry"; import { getPageBySlug } from "$lib/registry";
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte"; import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
@@ -37,8 +37,8 @@
if (path === "/kit") return "/ " + t("header.uiKit"); if (path === "/kit") return "/ " + t("header.uiKit");
const match = /^\/tools\/([^/]+)$/.exec(path); const match = /^\/tools\/([^/]+)$/.exec(path);
if (match) { if (match) {
const tool = getTool(match[1]); const page = getPageBySlug(match[1]);
if (tool) return "/ " + toolTitle(tool); if (page) return "/ " + pageTitle(page);
} }
return "/ " + t("header.home"); return "/ " + t("header.home");
} }
+11 -11
View File
@@ -5,9 +5,9 @@
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte"; import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
import ToolCard from "$lib/components/ToolCard.svelte"; import ToolCard from "$lib/components/ToolCard.svelte";
import { import {
searchTools, pageDescription,
toolDescription, pageTitle,
toolTitle, searchPages,
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { TOOL_ICONS } from "$lib/tool-icons"; import { TOOL_ICONS } from "$lib/tool-icons";
@@ -19,10 +19,10 @@
PREVIEW_GROUPS.map((g) => ({ PREVIEW_GROUPS.map((g) => ({
id: g.id, id: g.id,
label: t(`categories.${g.id}`), label: t(`categories.${g.id}`),
tools: searchTools(g.tools, query).filter( pages: searchPages(g.pages, query).filter(
() => category === "all" || category === g.id, () => category === "all" || category === g.id,
), ),
})).filter((g) => g.tools.length > 0), })).filter((g) => g.pages.length > 0),
); );
</script> </script>
@@ -31,14 +31,14 @@
<CatalogToolbar bind:query bind:category /> <CatalogToolbar bind:query bind:category />
<div class="catalog-groups"> <div class="catalog-groups">
{#each groups as group (group.id)} {#each groups as group (group.id)}
<CatalogGroup label={group.label} count={group.tools.length}> <CatalogGroup label={group.label} count={group.pages.length}>
{#each group.tools as tool, i (tool.id)} {#each group.pages as page, i (page.slug)}
<ToolCard <ToolCard
title={toolTitle(tool)} title={pageTitle(page)}
id={tool.id} slug={page.slug}
description={toolDescription(tool)} description={pageDescription(page)}
index={i + 1} index={i + 1}
icon={TOOL_ICONS[tool.id]} icon={TOOL_ICONS[page.slug]}
/> />
{/each} {/each}
</CatalogGroup> </CatalogGroup>
+8 -8
View File
@@ -23,7 +23,7 @@
import Toggle from "$lib/components/ui/Toggle.svelte"; import Toggle from "$lib/components/ui/Toggle.svelte";
import { Download, ImageOff, Plus, Trash2 } from "@lucide/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"; import { TOOL_ICONS } from "$lib/tool-icons";
let mode = $state("preview"); let mode = $state("preview");
@@ -34,8 +34,8 @@
let lossless = $state(true); let lossless = $state(true);
let animated = $state(false); let animated = $state(false);
const showcaseTools = TOOLS.filter((tool) => const showcasePages = PAGES.filter((page) =>
["linear-gradient-png", "resize-png", "quantize-png"].includes(tool.id), ["linear-gradient-png", "resize-png", "quantize-png"].includes(page.slug),
); );
</script> </script>
@@ -140,13 +140,13 @@
<Panel title="Tools" eyebrow="catalog"> <Panel title="Tools" eyebrow="catalog">
<div class="tool-grid"> <div class="tool-grid">
{#each showcaseTools as tool, i (tool.id)} {#each showcasePages as page, i (page.slug)}
<ToolCard <ToolCard
title={tool.title} title={page.title}
id={tool.id} slug={page.slug}
description={tool.description} description={page.description}
index={i + 1} index={i + 1}
icon={TOOL_ICONS[tool.id]} icon={TOOL_ICONS[page.slug]}
/> />
{/each} {/each}
</div> </div>
+11 -11
View File
@@ -5,9 +5,9 @@
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte"; import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
import ToolCard from "$lib/components/ToolCard.svelte"; import ToolCard from "$lib/components/ToolCard.svelte";
import { import {
searchTools, pageDescription,
toolDescription, pageTitle,
toolTitle, searchPages,
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { TOOL_ICONS } from "$lib/tool-icons"; import { TOOL_ICONS } from "$lib/tool-icons";
@@ -19,10 +19,10 @@
PREVIEW_GROUPS.map((g) => ({ PREVIEW_GROUPS.map((g) => ({
id: g.id, id: g.id,
label: t(`categories.${g.id}`), label: t(`categories.${g.id}`),
tools: searchTools(g.tools, query).filter( pages: searchPages(g.pages, query).filter(
() => category === "all" || category === g.id, () => category === "all" || category === g.id,
), ),
})).filter((g) => g.tools.length > 0), })).filter((g) => g.pages.length > 0),
); );
</script> </script>
@@ -31,14 +31,14 @@
<CatalogToolbar bind:query bind:category /> <CatalogToolbar bind:query bind:category />
<div class="catalog-groups"> <div class="catalog-groups">
{#each groups as group (group.id)} {#each groups as group (group.id)}
<CatalogGroup label={group.label} count={group.tools.length}> <CatalogGroup label={group.label} count={group.pages.length}>
{#each group.tools as tool, i (tool.id)} {#each group.pages as page, i (page.slug)}
<ToolCard <ToolCard
title={toolTitle(tool)} title={pageTitle(page)}
id={tool.id} slug={page.slug}
description={toolDescription(tool)} description={pageDescription(page)}
index={i + 1} index={i + 1}
icon={TOOL_ICONS[tool.id]} icon={TOOL_ICONS[page.slug]}
/> />
{/each} {/each}
</CatalogGroup> </CatalogGroup>
+6 -5
View File
@@ -2,7 +2,7 @@
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import SchemaToolView from "$lib/components/SchemaToolView.svelte"; import SchemaToolView from "$lib/components/SchemaToolView.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { getTool } from "$lib/registry"; import { getPageBySlug, getTool } from "$lib/registry";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte"; import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
interface Props { interface Props {
@@ -10,15 +10,16 @@
} }
let { data }: Props = $props(); 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> </script>
<svelte:head> <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" /> <meta name="robots" content="noindex, nofollow" />
</svelte:head> </svelte:head>
{#if !tool} {#if !page || !tool}
<div class="notfound"> <div class="notfound">
<EmptyState <EmptyState
title={t("errors.notFound")} title={t("errors.notFound")}
@@ -27,7 +28,7 @@
/> />
</div> </div>
{:else} {:else}
<SchemaToolView {tool} /> <SchemaToolView {page} {tool} />
{/if} {/if}
<style> <style>
+2 -2
View File
@@ -1,9 +1,9 @@
import { TOOLS } from "$lib/registry"; import { PAGES } from "$lib/registry";
export const prerender = true; export const prerender = true;
export function entries() { export function entries() {
return TOOLS.map((tool) => ({ id: tool.id })); return PAGES.map((page) => ({ id: page.slug }));
} }
export function load({ params }: { params: { id: string } }) { export function load({ params }: { params: { id: string } }) {