diff --git a/web/src/lib/catalog.ts b/web/src/lib/catalog.ts index 76b1629..6ec50ad 100644 --- a/web/src/lib/catalog.ts +++ b/web/src/lib/catalog.ts @@ -1,17 +1,17 @@ import { CATEGORY_IDS } from "./categories"; -import { TOOLS, type ToolEntry } from "./registry"; +import { PAGES, type Page } from "./registry"; export type PreviewGroup = { id: string; - tools: ToolEntry[]; + pages: Page[]; }; /** - * Preview-каталог: полный реестр инструментов, сгруппированный по категориям. + * Preview-каталог: все страницы, сгруппированные по категориям. */ export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({ id: category, - tools: TOOLS.filter((tool) => tool.category === category), -})).filter((group) => group.tools.length > 0); + pages: PAGES.filter((page) => page.category === category), +})).filter((group) => group.pages.length > 0); -export const PREVIEW_TOTAL = TOOLS.length; +export const PREVIEW_TOTAL = PAGES.length; diff --git a/web/src/lib/components/SchemaToolView.svelte b/web/src/lib/components/SchemaToolView.svelte index b58950c..c039f4b 100644 --- a/web/src/lib/components/SchemaToolView.svelte +++ b/web/src/lib/components/SchemaToolView.svelte @@ -7,12 +7,12 @@ import type { PixelImage } from "$lib/core/types"; import { execute } from "$lib/executor"; import { - toolDescription, - toolTitle, + pageDescription, + pageTitle, verdictText, } from "$lib/i18n/schema-tool-strings"; import { t } from "$lib/i18n/t"; - import type { FileResult, ToolEntry } from "$lib/registry"; + import type { FileResult, Page, Tool } from "$lib/registry"; import { applySourceDefaults, defaultSchemaParams, @@ -22,12 +22,13 @@ import { downloadZip } from "$lib/zip"; interface Props { - tool: ToolEntry; + page: Page; + tool: Tool; } type DisplayError = | { kind: "i18n"; key: string; vars?: ErrorVars } | { kind: "plain"; text: string }; - let { tool }: Props = $props(); + let { page, tool }: Props = $props(); const schema = $derived( tool.schema as ToolSchema> | undefined, @@ -141,7 +142,7 @@ try { if (resultKind === "files") { if (!fileResult) return; - await downloadZip(fileResult.files, `${tool.id}.zip`); + await downloadZip(fileResult.files, `${page.slug}.zip`); return; } if (!result) return; @@ -150,7 +151,7 @@ ? Number(values[out.qualityParamId]) / 100 : undefined; const blob = await encode(result, out?.mime ?? "image/png", quality); - downloadBlob(blob, `${tool.id}.${out?.ext ?? "png"}`); + downloadBlob(blob, `${page.slug}.${out?.ext ?? "png"}`); } catch (e) { displayError = toDisplayError(e); } finally { @@ -175,7 +176,7 @@ if (!textResult) return; try { const blob = new Blob([textResult], { type: "text/plain" }); - downloadBlob(blob, `${tool.id}.txt`); + downloadBlob(blob, `${page.slug}.txt`); } catch (e) { displayError = toDisplayError(e); } @@ -208,8 +209,8 @@
-

{toolTitle(tool)}

-

{toolDescription(tool)}

+

{pageTitle(page)}

+

{pageDescription(page)}

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

> = { - /** Внутреннее имя: ключ i18n `tools..*`, `toolId` worker-протокола, запись пайплайна. */ +export type Tool

> = { + /** Внутреннее имя: ключ словаря инструмента, `toolId` worker-протокола, запись пайплайна. */ id: string; - /** - * Адрес страницы `/tools/` и база имени скачиваемого файла. Инструмент - * работает с любым форматом, поэтому png-интент держит slug, а не id. - */ - slug: string; - title: string; - description: string; - category: CategoryId; schema: ToolSchema

; /** Чем управляется инструмент: входным изображением, текстом или ничем. */ input: InputMode; @@ -91,7 +86,28 @@ export type ToolEntry

> = { domOnly?: boolean; /** Формат/качество скачивания результата; по умолчанию — PNG. */ output?: OutputFormat; - icon?: string; +}; + +/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */ +export type PageStep = { + id: string; + params?: Record; +}; + +/** + * Страница: уникальный адрес, тексты и то, что на ней выполняется. `steps` — + * упорядоченный список инструментов, потому что страница может быть готовой + * цепочкой; исполняет их `SchemaToolView`, и на сегодня шаг ровно один. + */ +export type Page = { + /** Адрес `/tools/`, имя скачиваемого файла и ключ иконки. */ + slug: string; + /** EN-строка страницы; перевод лежит в словаре по slug. */ + title: string; + /** EN-строка страницы; перевод лежит в словаре по slug. */ + description: string; + category: CategoryId; + steps: PageStep[]; }; /** Гарантирует наличие входного изображения для трансформаторов. */ diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index dfdb800..6693fff 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -4,9 +4,9 @@ import Footer from "$lib/components/layout/Footer.svelte"; import TopBar from "$lib/components/layout/TopBar.svelte"; import { initLocale } from "$lib/i18n/locale.svelte"; - import { toolTitle } from "$lib/i18n/schema-tool-strings"; + import { pageTitle } from "$lib/i18n/schema-tool-strings"; import { t } from "$lib/i18n/t"; - import { getTool } from "$lib/registry"; + import { getPageBySlug } from "$lib/registry"; import { getTheme, initTheme, setTheme } from "$lib/theme.svelte"; import type { Snippet } from "svelte"; import { onMount } from "svelte"; @@ -37,8 +37,8 @@ if (path === "/kit") return "/ " + t("header.uiKit"); const match = /^\/tools\/([^/]+)$/.exec(path); if (match) { - const tool = getTool(match[1]); - if (tool) return "/ " + toolTitle(tool); + const page = getPageBySlug(match[1]); + if (page) return "/ " + pageTitle(page); } return "/ " + t("header.home"); } diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index d36d844..c30b4f1 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -5,9 +5,9 @@ import CatalogToolbar from "$lib/components/CatalogToolbar.svelte"; import ToolCard from "$lib/components/ToolCard.svelte"; import { - searchTools, - toolDescription, - toolTitle, + pageDescription, + pageTitle, + searchPages, } from "$lib/i18n/schema-tool-strings"; import { t } from "$lib/i18n/t"; import { TOOL_ICONS } from "$lib/tool-icons"; @@ -19,10 +19,10 @@ PREVIEW_GROUPS.map((g) => ({ id: g.id, label: t(`categories.${g.id}`), - tools: searchTools(g.tools, query).filter( + pages: searchPages(g.pages, query).filter( () => category === "all" || category === g.id, ), - })).filter((g) => g.tools.length > 0), + })).filter((g) => g.pages.length > 0), ); @@ -31,14 +31,14 @@

{#each groups as group (group.id)} - - {#each group.tools as tool, i (tool.id)} + + {#each group.pages as page, i (page.slug)} {/each} diff --git a/web/src/routes/kit/+page.svelte b/web/src/routes/kit/+page.svelte index 02f9f2a..edbc752 100644 --- a/web/src/routes/kit/+page.svelte +++ b/web/src/routes/kit/+page.svelte @@ -23,7 +23,7 @@ import Toggle from "$lib/components/ui/Toggle.svelte"; import { Download, ImageOff, Plus, Trash2 } from "@lucide/svelte"; - import { TOOLS } from "$lib/registry"; + import { PAGES } from "$lib/registry"; import { TOOL_ICONS } from "$lib/tool-icons"; let mode = $state("preview"); @@ -34,8 +34,8 @@ let lossless = $state(true); let animated = $state(false); - const showcaseTools = TOOLS.filter((tool) => - ["linear-gradient-png", "resize-png", "quantize-png"].includes(tool.id), + const showcasePages = PAGES.filter((page) => + ["linear-gradient-png", "resize-png", "quantize-png"].includes(page.slug), ); @@ -140,13 +140,13 @@
- {#each showcaseTools as tool, i (tool.id)} + {#each showcasePages as page, i (page.slug)} {/each}
diff --git a/web/src/routes/list-tools/+page.svelte b/web/src/routes/list-tools/+page.svelte index d36d844..c30b4f1 100644 --- a/web/src/routes/list-tools/+page.svelte +++ b/web/src/routes/list-tools/+page.svelte @@ -5,9 +5,9 @@ import CatalogToolbar from "$lib/components/CatalogToolbar.svelte"; import ToolCard from "$lib/components/ToolCard.svelte"; import { - searchTools, - toolDescription, - toolTitle, + pageDescription, + pageTitle, + searchPages, } from "$lib/i18n/schema-tool-strings"; import { t } from "$lib/i18n/t"; import { TOOL_ICONS } from "$lib/tool-icons"; @@ -19,10 +19,10 @@ PREVIEW_GROUPS.map((g) => ({ id: g.id, label: t(`categories.${g.id}`), - tools: searchTools(g.tools, query).filter( + pages: searchPages(g.pages, query).filter( () => category === "all" || category === g.id, ), - })).filter((g) => g.tools.length > 0), + })).filter((g) => g.pages.length > 0), ); @@ -31,14 +31,14 @@
{#each groups as group (group.id)} - - {#each group.tools as tool, i (tool.id)} + + {#each group.pages as page, i (page.slug)} {/each} diff --git a/web/src/routes/tools/[id]/+page.svelte b/web/src/routes/tools/[id]/+page.svelte index c3ba498..9f31b00 100644 --- a/web/src/routes/tools/[id]/+page.svelte +++ b/web/src/routes/tools/[id]/+page.svelte @@ -2,7 +2,7 @@ import EmptyState from "$lib/components/EmptyState.svelte"; import SchemaToolView from "$lib/components/SchemaToolView.svelte"; import { t } from "$lib/i18n/t"; - import { getTool } from "$lib/registry"; + import { getPageBySlug, getTool } from "$lib/registry"; import { SlidersHorizontal as ToolIcon } from "@lucide/svelte"; interface Props { @@ -10,15 +10,16 @@ } let { data }: Props = $props(); - const tool = $derived(getTool(data.id)); + const page = $derived(getPageBySlug(data.id)); + const tool = $derived(page ? getTool(page.steps[0].id) : undefined); - easy-png-tools / {tool?.title ?? t("toolPage.fallbackTitle")} + easy-png-tools / {page?.title ?? t("toolPage.fallbackTitle")} -{#if !tool} +{#if !page || !tool}
{:else} - + {/if}