From 58e75f23345b956d4068ff16ef90805031eae1c8 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Fri, 4 Sep 2026 17:07:38 +0500 Subject: [PATCH] refactor: separate registry file --- web/src/lib/registry-helpers.ts | 25 + web/src/lib/registry-new/types.ts | 23 + web/src/lib/registry.ts | 3815 +---------------------------- web/src/lib/registry/alpha.ts | 686 ++++++ web/src/lib/registry/analyze.ts | 289 +++ web/src/lib/registry/color.ts | 501 ++++ web/src/lib/registry/convert.ts | 354 +++ web/src/lib/registry/filters.ts | 209 ++ web/src/lib/registry/generate.ts | 942 +++++++ web/src/lib/registry/geometry.ts | 657 +++++ web/src/lib/registry/text.ts | 315 +++ 11 files changed, 4043 insertions(+), 3773 deletions(-) create mode 100644 web/src/lib/registry-helpers.ts create mode 100644 web/src/lib/registry-new/types.ts create mode 100644 web/src/lib/registry/alpha.ts create mode 100644 web/src/lib/registry/analyze.ts create mode 100644 web/src/lib/registry/color.ts create mode 100644 web/src/lib/registry/convert.ts create mode 100644 web/src/lib/registry/filters.ts create mode 100644 web/src/lib/registry/generate.ts create mode 100644 web/src/lib/registry/geometry.ts create mode 100644 web/src/lib/registry/text.ts diff --git a/web/src/lib/registry-helpers.ts b/web/src/lib/registry-helpers.ts new file mode 100644 index 0000000..bdb34d6 --- /dev/null +++ b/web/src/lib/registry-helpers.ts @@ -0,0 +1,25 @@ +import { ToolError } from "./core/errors"; + +export function num(params: Record, id: string): number { + const v = params[id]; + if (typeof v !== "number" || !Number.isFinite(v)) { + throw new ToolError("errors.paramNumber", { id }); + } + return v; +} + +export function str(params: Record, id: string): string { + const v = params[id]; + if (typeof v !== "string") { + throw new ToolError("errors.paramString", { id }); + } + return v; +} + +export function bool(params: Record, id: string): boolean { + const v = params[id]; + if (typeof v !== "boolean") { + throw new ToolError("errors.paramBool", { id }); + } + return v; +} diff --git a/web/src/lib/registry-new/types.ts b/web/src/lib/registry-new/types.ts new file mode 100644 index 0000000..6f7bfb8 --- /dev/null +++ b/web/src/lib/registry-new/types.ts @@ -0,0 +1,23 @@ +import type { CategoryId } from "../categories"; +import type { ToolSchema } from "../registry-schema"; +import type { PixelImage } from "../core/types"; + +/** + * Инструмент нового registry: полностью типизирован на `Params`, схема — + * обязательна (единый источник дефолтов/валидации/UI). Старого `ParamDef[]` + * нет — это отдельная система от старого UI. + */ +export type ToolEntry

> = { + id: string; + title: string; + description: string; + category: CategoryId; + schema: ToolSchema

; + run: (img: PixelImage, params: P) => Promise | PixelImage; + generate?: (params: P) => Promise | PixelImage; + toText?: (img: PixelImage, params: P) => Promise | string; + runFromText?: (text: string, params: P) => Promise | PixelImage; + textToText?: (text: string) => Promise | string; + preview?: (img: PixelImage, params: P) => Promise | PixelImage; + icon?: string; +}; diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 69e4501..bee8d8a 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -1,150 +1,16 @@ import type { CategoryId } from "./categories"; -import { field, toolSchema, type ToolSchema } from "./registry-schema"; -import { ToolError } from "./core/errors"; -import { - colorMask, - extractAlphaMask, - flattenOntoColor, - hardenAlpha, - invertAlpha, - removeColorToAlpha, - roundCorners, - setAlphaChannel, -} from "./core/alpha"; -import { hasTransparency, isGrayscale, orientationOf } from "./core/analyze"; -import { backgroundMaskPreview, removeBackground } from "./core/background"; -import { - closingImage, - contourImage, - dilateImage, - erodeImage, - openingImage, - strokeImage, -} from "./core/morphology"; -import { gaussianBlur, sharpen as sharpenImage } from "./core/convolution"; -import { gradientImage, noiseImage, solidImage } from "./core/generate"; -import { - rotateFreeImage, - skewImage, - transformImage, - zoomImage, -} from "./core/affine"; -import { vignette } from "./core/effects"; -import { - autoContrast, - brightnessContrast, - changeHue, - extractChannel, - gammaCorrection, - grayscale, - invert, - posterize, - sepia, - setOpacity, - swapChannels, - temperature, - tint, - thresholdBlackWhite, - twoColors, - type ChannelSwapPair, - type RgbChannel, -} from "./core/color"; -import { - centerByAlpha, - crop, - expandCanvas, - flip, - resize, - rotate90, - tile, -} from "./core/geometry"; -import { - decodeSvgText, - decodeTextImage, - jpegRoundtrip, - toBase64, - toDataUrl, - type OutputMime, -} from "./core/io"; -import { - drawImageWatermark, - drawTextBlock, - drawTextTile, - renderEmoji, - renderTextToImage, - type TextFont, -} from "./core/domText"; -import { getOverlay } from "./tools/overlay-store.svelte"; -import { - base64ToBytes, - bytesToImage, - imageToByteRows, - imageToRgbValues, - looksLikePng, - rgbValuesToImage, - stripDataUri, -} from "./core/textio"; -import { colorSpectrum, drawGrid, randomColorBlocks } from "./core/gen-tools"; -import { - COMPRESSION_LEVELS, - findMaxColorsWithin, - type CompressionLevel, -} from "./core/compress"; -import { encode } from "./core/io"; -import { t } from "./i18n/t"; -import { formatStamp } from "./core/datefmt"; -import type { Position9 } from "./core/textdraw"; -import { - analogousSet, - complementarySet, - monochromaticSet, - parseHexList, - renderBlend, - renderSwatches, - renderWheel, - shadeSet, - sortPalette, - stepColors, - mixColors, - tetradicSet, - triadicSet, - type SortKey, -} from "./core/palette"; -import { - extractByColor, - isGrayscaleish, - luma01, - rarityPredicate, - renderPredicateMask, -} from "./core/masks"; -import { renderSpace, SPACES, type SpaceId } from "./core/channels"; -import { ditherImage, mapToNearest, quantizeImage } from "./core/quantize"; -import { - boxTest, - circleTest, - renderShape, - starTest, - wavyTest, -} from "./core/shapes"; -import { - changeCanvasSize, - cropToRatio, - forceOrientation, - padToRatio, - symmetricCopy, - trimToContent, - type Anchor9, -} from "./core/geometry"; -import { - addNoise, - defringe, - featherAlpha, - pixelate, - shuffleBlocks, - silhouette, -} from "./core/pixel-fx"; -import { hexToPixels, pixelsToHex } from "./core/text"; -import { clonePixelImage, type PixelImage } from "./core/types"; +import type { ToolSchema } from "./registry-schema"; +import type { OutputMime } from "./core/io"; +import type { PixelImage } from "./core/types"; + +import { convertEntries } from "./registry/convert"; +import { geometryEntries } from "./registry/geometry"; +import { alphaEntries } from "./registry/alpha"; +import { colorEntries } from "./registry/color"; +import { analyzeEntries } from "./registry/analyze"; +import { generateEntries } from "./registry/generate"; +import { textEntries } from "./registry/text"; +import { filterEntries } from "./registry/filters"; export type ParamDef = | { @@ -197,22 +63,18 @@ export type ToolEntry

> = { category: CategoryId; sourceMode?: SourceMode; params: ParamDef[]; - /** Новая типизированная схема для нового UI. Опциональна — старый UI её не использует. */ schema?: ToolSchema

; run?: (img: PixelImage, params: P) => Promise | PixelImage; generate?: (params: P) => Promise | PixelImage; toText?: (img: PixelImage, params: P) => Promise | string; runFromText?: (text: string, params: P) => Promise | PixelImage; - /** Текстовый источник + текстовый результат без промежуточного изображения. */ textToText?: (text: string) => Promise | string; preview?: (img: PixelImage, params: P) => Promise | PixelImage; popularity?: number; icon?: string; resultType?: "image" | "info" | "text"; output?: OutputFormat; - /** Инструменту нужен DOM (canvas): исполняется только напрямую, без воркера. */ domOnly?: boolean; - /** Инструменту нужна вторая картинка-источник (знак), загружаемая на странице. */ needsOverlaySource?: boolean; }; @@ -225,3631 +87,38 @@ export function isChainable(tool: ToolEntry): boolean { ); } -function num(params: Record, id: string): number { - const v = params[id]; - if (typeof v !== "number" || !Number.isFinite(v)) { - throw new ToolError("errors.paramNumber", { id }); - } - return v; -} - -function str(params: Record, id: string): string { - const v = params[id]; - if (typeof v !== "string") { - throw new ToolError("errors.paramString", { id }); - } - return v; -} - -function bool(params: Record, id: string): boolean { - const v = params[id]; - if (typeof v !== "boolean") { - throw new ToolError("errors.paramBool", { id }); - } - return v; -} - -type MaskToolSpec = { - id: string; - title: string; - description: string; - defaultMode: "binary" | "highlight"; - predicate: ( - img: PixelImage, - p: Record, - ) => (r: number, g: number, b: number, a: number) => boolean; - extraParams?: ToolEntry["params"]; -}; - -const MASK_TOOLS: MaskToolSpec[] = [ - { - id: "show-transparent-png", - title: "Show Transparent Areas PNG", - description: - "Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.", - defaultMode: "highlight", - predicate: (_img, _p) => (_r, _g, _b, a) => a < 255, - }, - { - id: "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.", - defaultMode: "binary", - predicate: (_i, p) => (r, g, b) => - isGrayscaleish(r, g, b, num(p, "tolerance")), - extraParams: [ - { - id: "tolerance", - label: "Channel tolerance", - type: "slider", - min: 0, - max: 64, - step: 1, - default: 0, - }, - ], - }, - { - id: "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.", - defaultMode: "binary", - predicate: (_i, p) => (r, g, b) => - !isGrayscaleish(r, g, b, num(p, "tolerance")), - extraParams: [ - { - id: "tolerance", - label: "Channel tolerance", - type: "slider", - min: 0, - max: 64, - step: 1, - default: 8, - }, - ], - }, - { - id: "light-pixel-mask-png", - title: "Light Pixel Mask PNG", - description: "Selects pixels brighter than the luminance threshold.", - defaultMode: "binary", - predicate: (_i, p) => (r, g, b) => - luma01(r, g, b) >= num(p, "threshold") / 100, - extraParams: [ - { - id: "threshold", - label: "Luminance threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 70, - }, - ], - }, - { - id: "dark-pixel-mask-png", - title: "Dark Pixel Mask PNG", - description: "Selects pixels darker than the luminance threshold.", - defaultMode: "binary", - predicate: (_i, p) => (r, g, b) => - luma01(r, g, b) <= num(p, "threshold") / 100, - extraParams: [ - { - id: "threshold", - label: "Luminance threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 30, - }, - ], - }, - { - id: "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.", - defaultMode: "binary", - predicate: (img, p) => rarityPredicate(img, num(p, "rarity")), - extraParams: [ - { - id: "rarity", - label: "Max occurrences", - type: "slider", - min: 1, - max: 50, - step: 1, - default: 1, - }, - ], - }, -]; - -function maskEntries(): ToolEntry[] { - return MASK_TOOLS.map((spec) => ({ - id: spec.id, - title: spec.title, - description: spec.description, - category: "analyze" as const, - params: [ - ...(spec.extraParams ?? []), - { - id: "mode", - label: "Mask mode", - type: "select" as const, - default: spec.defaultMode, - options: [ - { value: "binary", label: "Black & white mask" }, - { value: "highlight", label: "Color highlight" }, - ], - }, - { - id: "color", - label: "Highlight color", - type: "color", - default: "#ff00aa", - }, - { - id: "opacity", - label: "Highlight opacity, %", - type: "slider", - min: 0, - max: 100, - step: 5, - default: 70, - }, - ], - run: (img, p) => - renderPredicateMask(img, spec.predicate(img, p), { - mode: str(p, "mode") === "highlight" ? "highlight" : "binary", - color: str(p, "color"), - opacityPercent: num(p, "opacity"), - }), - })); -} - -type SpaceEntry = { - id: SpaceId; - suffix: string; - title: string; - description: string; -}; - -const CHANNEL_SPACES: SpaceEntry[] = [ - { - id: "hsl", - suffix: "hsl", - title: "Split PNG into HSL", - description: - "Decomposes the image into Hue, Saturation and Lightness components.", - }, - { - id: "hsv", - suffix: "hsv", - title: "Split PNG into HSV", - description: - "Decomposes the image into Hue, Saturation and Value (brightness) components.", - }, - { - id: "hsi", - suffix: "hsi", - title: "Split PNG into HSI", - description: - "Decomposes the image into Hue, Saturation and Intensity components.", - }, - { - id: "cmyk", - suffix: "cmyk", - title: "Convert PNG to CMYK Colors", - description: - "Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.", - }, - { - id: "ycbcr", - suffix: "ycbcr", - title: "Convert PNG to YCbCr Colors", - description: - "Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.", - }, - { - id: "lab", - suffix: "lab", - title: "Convert PNG to LAB Colors", - description: - "Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.", - }, -]; - -function channelEntries(): ToolEntry[] { - return CHANNEL_SPACES.map((space) => { - const components = SPACES[space.id].components; - return { - id: `png-to-${space.suffix}`, - title: space.title, - description: space.description, - category: "color" as const, - params: [ - { - id: "component", - label: "Component", - type: "select" as const, - default: components[0], - options: components.map((c) => ({ - value: c, - label: c.toUpperCase(), - })), - }, - { - id: "display", - label: "Display mode", - type: "select" as const, - default: "gray", - options: [ - { value: "gray", label: "Grayscale" }, - { value: "color", label: "Space as RGB" }, - ], - }, - ], - run: (img, p) => - renderSpace( - img, - space.id, - str(p, "component"), - str(p, "display") === "color" ? "color" : "gray", - ), - } satisfies ToolEntry; - }); -} - -function paletteParams(baseDefault: string) { - return [ - { - id: "baseColor", - label: "Base color", - type: "color" as const, - default: baseDefault, - }, - { - id: "width", - label: "Width", - type: "slider" as const, - min: 128, - max: 1024, - step: 16, - default: 512, - }, - { - id: "layout", - label: "Layout", - type: "select" as const, - default: "grid", - options: [ - { value: "grid", label: "Grid" }, - { value: "strip", label: "Strip" }, - ], - }, - ]; -} - -function decodeToPng( - id: string, - title: string, - description: string, -): ToolEntry { - return { - id, - title, - description, - category: "convert", - params: [], - run: (img) => clonePixelImage(img), - }; -} - -function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { - const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); - if (!match) { - throw new ToolError("errors.badHex", { value: hex }); - } - const d = match[1]; - return [ - parseInt(d.slice(0, 2), 16), - parseInt(d.slice(2, 4), 16), - parseInt(d.slice(4, 6), 16), - alpha, - ]; -} - -interface AddBorderParams { - thickness: number; - color: string; -} - -const addBorderSchema = toolSchema({ - thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }), - color: field.color({ default: "#000000" }), -}); +const _convert = convertEntries(); +const _geometry = geometryEntries(); +const _alpha = alphaEntries(); +const _color = colorEntries(); +const _analyze = analyzeEntries(); +const _generate = generateEntries(); +const _text = textEntries(); +const _filters = filterEntries(); export const TOOLS: ToolEntry[] = [ - ...channelEntries(), - ...maskEntries(), - decodeToPng( - "jpg-to-png", - "Convert JPG to PNG", - "Opens a JPEG and saves it as lossless PNG. Transparency, if present, is preserved.", - ), - decodeToPng( - "webp-to-png", - "Convert WebP to PNG", - "Re-encodes a WebP image into universal PNG.", - ), - decodeToPng( - "gif-to-png", - "Convert GIF to PNG", - "Extracts the first frame of a GIF animation and saves it as PNG.", - ), - decodeToPng( - "bmp-to-png", - "Convert BMP to PNG", - "Re-encodes BMP into compact lossless PNG.", - ), - decodeToPng( - "ico-to-png", - "Convert ICO to PNG", - "Turns an .ico icon into a regular PNG of the chosen size.", - ), - { - id: "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", - params: [], - run: (img) => flattenOntoColor(img, "#000000"), - output: { mime: "image/bmp", ext: "bmp" }, - }, - { - id: "png-to-base64", - title: "PNG to Base64", - description: - "Encodes the image into a base64 string for embedding in code or styles.", - category: "convert", - params: [], - resultType: "text", - toText: (img) => toBase64(img), - }, - { - id: "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", - sourceMode: "text", - params: [], - run: (img) => clonePixelImage(img), - }, - { - id: "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", - params: [], - resultType: "text", - toText: (img) => toDataUrl(img), - }, - { - id: "data-uri-to-png", - title: "Data URI to PNG", - description: "Decodes data:image/…;base64,… back into an image file.", - category: "convert", - sourceMode: "text", - params: [], - run: (img) => clonePixelImage(img), - }, - { - id: "png-to-hex", - title: "PNG to HEX pixels", - description: - "Shows all pixels as rrggbbaa hex values — row by row, space separated.", - category: "convert", - params: [], - resultType: "text", - toText: (img) => pixelsToHex(img), - }, - { - id: "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", - sourceMode: "text", - params: [ - { - id: "width", - label: "Image width", - type: "number", - min: 1, - max: 10000, - step: 1, - default: 1, - }, - ], - runFromText: (text, p) => hexToPixels(text, Math.trunc(Number(p["width"]))), - run: (img) => clonePixelImage(img), - }, - { - id: "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", - params: [], - resultType: "text", - toText: (img) => imageToByteRows(img), - }, - { - id: "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", - sourceMode: "text", - params: [ - { - id: "width", - label: "Image width", - type: "number", - min: 1, - max: 10000, - step: 1, - default: 32, - }, - ], - runFromText: (text, p) => - bytesToImage( - text, - Math.trunc(num(p as Record, "width")), - ), - run: (img) => clonePixelImage(img), - }, - { - id: "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", - params: [], - resultType: "text", - toText: (img) => imageToRgbValues(img), - }, - { - id: "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", - sourceMode: "text", - params: [ - { - id: "width", - label: "Image width", - type: "number", - min: 1, - max: 10000, - step: 1, - default: 32, - }, - ], - runFromText: (text, p) => - rgbValuesToImage( - text, - Math.trunc(num(p as Record, "width")), - ), - run: (img) => clonePixelImage(img), - }, - { - id: "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", - params: [ - { - id: "width", - label: "Width (0 = auto)", - type: "number", - min: 0, - max: 20000, - step: 1, - default: 0, - }, - { - id: "height", - label: "Height (0 = auto)", - type: "number", - min: 0, - max: 20000, - step: 1, - default: 0, - }, - { - id: "keepAspect", - label: "Keep aspect ratio", - type: "checkbox", - default: true, - }, - ], - run: (img, p) => { - const keepAspect = p["keepAspect"] === true; - let w = Math.trunc(num(p, "width")); - let h = Math.trunc(num(p, "height")); - if (keepAspect) { - if (w > 0 && h > 0) { - const scale = Math.min(w / img.width, h / img.height); - w = Math.max(1, Math.round(img.width * scale)); - h = Math.max(1, Math.round(img.height * scale)); - } else if (w > 0) { - h = Math.max(1, Math.round((img.height / img.width) * w)); - } else if (h > 0) { - w = Math.max(1, Math.round((img.width / img.height) * h)); - } - } - if (w <= 0 || h <= 0) { - throw new ToolError("errors.resizeSize"); - } - return resize(img, w, h); - }, - }, - { - id: "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", - params: [ - { - id: "x", - label: "X (from left)", - type: "number", - min: -100000, - max: 100000, - step: 1, - default: 0, - }, - { - id: "y", - label: "Y (from top)", - type: "number", - min: -100000, - max: 100000, - step: 1, - default: 0, - }, - { - id: "width", - label: "Area width", - type: "number", - min: -100000, - max: 100000, - step: 1, - default: 0, - }, - { - id: "height", - label: "Area height", - type: "number", - min: -100000, - max: 100000, - step: 1, - default: 0, - }, - ], - run: (img, p) => { - const w = Math.trunc(num(p, "width")); - const h = Math.trunc(num(p, "height")); - if (w <= 0 || h <= 0) { - throw new ToolError("errors.cropSize"); - } - return crop(img, Math.trunc(num(p, "x")), Math.trunc(num(p, "y")), w, h); - }, - }, - { - id: "rotate-png", - title: "Rotate PNG", - description: "Rotates by 90°, 180° or 270° clockwise without quality loss.", - category: "geometry", - params: [ - { - id: "angle", - label: "Rotation angle", - type: "select", - default: "90", - options: [ - { value: "90", label: "90° clockwise" }, - { value: "180", label: "180°" }, - { value: "270", label: "270° clockwise" }, - ], - }, - ], - run: (img, p) => rotate90(img, Number(str(p, "angle")) / 90), - }, - { - id: "flip-png", - title: "Flip PNG", - description: "Mirrors horizontally or vertically without quality loss.", - category: "geometry", - params: [ - { - id: "axis", - label: "Flip axis", - type: "select", - default: "horizontal", - options: [ - { value: "horizontal", label: "Horizontal (left to right)" }, - { value: "vertical", label: "Vertical (top to bottom)" }, - ], - }, - ], - run: (img, p) => - flip(img, str(p, "axis") === "vertical" ? "vertical" : "horizontal"), - }, - { - id: "add-padding-png", - title: "Add padding to PNG", - description: - "Expands the canvas on all sides by the chosen number of pixels.", - category: "geometry", - params: [ - { - id: "padding", - label: "Padding, px", - type: "number", - min: 1, - max: 2000, - step: 1, - default: 10, - }, - { - id: "transparent", - label: "Transparent padding", - type: "checkbox", - default: true, - }, - { - id: "color", - label: "Padding color", - type: "color", - default: "#ffffff", - }, - ], - run: (img, p) => - expandCanvas( - img, - num(p, "padding"), - num(p, "padding"), - num(p, "padding"), - num(p, "padding"), - p["transparent"] === true ? undefined : str(p, "color"), - ), - }, - { - id: "add-border-png", - title: "Add border to PNG", - description: - "Draws a colored frame of the chosen thickness around the image.", - category: "geometry", - schema: addBorderSchema, - params: [ - { - id: "thickness", - label: "Border thickness, px", - type: "number", - min: 1, - max: 500, - step: 1, - default: 5, - }, - { id: "color", label: "Border color", type: "color", default: "#000000" }, - ], - run: (img, p) => - expandCanvas( - img, - num(p, "thickness"), - num(p, "thickness"), - num(p, "thickness"), - num(p, "thickness"), - str(p, "color"), - ), - }, - { - id: "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", - params: [ - { - id: "width", - label: "Canvas width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Canvas height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 600, - }, - { - id: "transparent", - label: "Transparent background", - type: "checkbox", - default: false, - }, - { - id: "color", - label: "Background color", - type: "color", - default: "#ffffff", - }, - ], - run: (img, p) => { - const width = Math.trunc(num(p, "width")); - const height = Math.trunc(num(p, "height")); - if (width <= 0 || height <= 0) { - throw new ToolError("errors.sizePositive"); - } - const left = Math.max(0, Math.floor((width - img.width) / 2)); - const top = Math.max(0, Math.floor((height - img.height) / 2)); - return expandCanvas( - img, - left, - top, - Math.max(0, width - img.width - left), - Math.max(0, height - img.height - top), - p["transparent"] === true ? undefined : str(p, "color"), - ); - }, - }, - { - id: "tile-png", - title: "Tile PNG", - description: "Repeats the image in a grid of the chosen columns and rows.", - category: "geometry", - params: [ - { - id: "columns", - label: "Columns", - type: "number", - min: 1, - max: 50, - step: 1, - default: 2, - }, - { - id: "rows", - label: "Rows", - type: "number", - min: 1, - max: 50, - step: 1, - default: 2, - }, - ], - run: (img, p) => tile(img, num(p, "columns"), num(p, "rows")), - }, - { - id: "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", - params: [ - { - id: "size", - label: "Diameter, % of smaller side", - type: "slider", - min: 20, - max: 100, - step: 1, - default: 100, - }, - { - id: "offsetX", - label: "Offset X, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - ], - run: (img, p) => - renderShape( - img, - circleTest(num(p, "size") / 200), - num(p, "offsetX") / 100, - num(p, "offsetY") / 100, - ), - }, - { - id: "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", - params: [ - { - id: "widthPct", - label: "Width, % of smaller side", - type: "slider", - min: 10, - max: 100, - step: 1, - default: 100, - }, - { - id: "heightPct", - label: "Height, % of smaller side", - type: "slider", - min: 10, - max: 100, - step: 1, - default: 100, - }, - { - id: "offsetX", - label: "Offset X, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - ], - run: (img, p) => - renderShape( - img, - boxTest(num(p, "widthPct") / 200, num(p, "heightPct") / 200), - num(p, "offsetX") / 100, - num(p, "offsetY") / 100, - ), - }, - { - id: "star-mask-png", - title: "Star Mask PNG", - description: - "Cuts the image into an n-pointed star with adjustable inner radius and rotation.", - category: "alpha", - params: [ - { - id: "points", - label: "Points", - type: "slider", - min: 3, - max: 12, - step: 1, - default: 5, - }, - { - id: "innerRadius", - label: "Inner radius, %", - type: "slider", - min: 10, - max: 90, - step: 1, - default: 45, - }, - { - id: "size", - label: "Outer radius, % of smaller side", - type: "slider", - min: 20, - max: 100, - step: 1, - default: 100, - }, - { - id: "rotation", - label: "Rotation, °", - type: "slider", - min: -180, - max: 180, - step: 1, - default: 0, - }, - { - id: "offsetX", - label: "Offset X, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - ], - run: (img, p) => - renderShape( - img, - starTest( - num(p, "points"), - num(p, "innerRadius") / 100, - num(p, "size") / 200, - num(p, "rotation"), - ), - num(p, "offsetX") / 100, - num(p, "offsetY") / 100, - ), - }, - { - id: "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", - params: [ - { - id: "size", - label: "Base radius, % of smaller side", - type: "slider", - min: 20, - max: 100, - step: 1, - default: 90, - }, - { - id: "amplitude", - label: "Wave amplitude, %", - type: "slider", - min: 2, - max: 30, - step: 1, - default: 8, - }, - { - id: "waves", - label: "Waves count", - type: "slider", - min: 3, - max: 24, - step: 1, - default: 8, - }, - { - id: "phase", - label: "Phase, °", - type: "slider", - min: 0, - max: 360, - step: 1, - default: 0, - }, - { - id: "offsetX", - label: "Offset X, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, %", - type: "slider", - min: -50, - max: 50, - step: 1, - default: 0, - }, - ], - run: (img, p) => - renderShape( - img, - wavyTest( - num(p, "size") / 200, - num(p, "amplitude") / 200, - num(p, "waves"), - num(p, "phase"), - ), - num(p, "offsetX") / 100, - num(p, "offsetY") / 100, - ), - }, - { - id: "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", - params: [], - run: (img) => centerByAlpha(img), - }, - { - id: "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", - params: [ - { - id: "radius", - label: "Radius, px", - type: "slider", - min: 1, - max: 32, - step: 1, - default: 4, - }, - ], - run: (img, p) => gaussianBlur(img, num(p, "radius")), - }, - { - id: "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", - params: [ - { - id: "strength", - label: "Strength, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - run: (img, p) => sharpenImage(img, num(p, "strength")), - }, - { - id: "grayscale-png", - title: "Grayscale PNG", - description: - "Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.", - category: "color", - params: [], - run: (img) => grayscale(img), - }, - { - id: "invert-colors-png", - title: "Invert colors PNG", - description: - "Inverts each color channel (255 − value). Alpha is unchanged.", - category: "color", - params: [], - run: (img) => invert(img), - }, - { - id: "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", - params: [ - { - id: "brightness", - label: "Brightness", - type: "slider", - min: -100, - max: 100, - step: 1, - default: 0, - }, - { - id: "contrast", - label: "Contrast", - type: "slider", - min: -100, - max: 100, - step: 1, - default: 0, - }, - ], - run: (img, p) => - brightnessContrast(img, num(p, "brightness"), num(p, "contrast")), - }, - { - id: "change-png-opacity", - title: "Change PNG opacity", - description: - "Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.", - category: "color", - params: [ - { - id: "percent", - label: "Opacity, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 100, - }, - ], - run: (img, p) => setOpacity(img, num(p, "percent")), - }, - { - id: "sepia-png", - title: "Sepia effect", - description: "Tints the image into the warm brown tones of classic sepia.", - category: "color", - params: [], - run: (img) => sepia(img), - }, - { - id: "change-png-hue", - title: "Change hue PNG", - description: - "Shifts the hue around the circle. Saturation and lightness are preserved.", - category: "color", - params: [ - { - id: "degrees", - label: "Hue shift, °", - type: "slider", - min: -180, - max: 180, - step: 1, - default: 0, - }, - ], - run: (img, p) => changeHue(img, num(p, "degrees")), - }, - { - id: "extract-channel-png", - title: "Extract channel PNG", - description: - "Keeps only the chosen channel — red, green or blue — as shades of gray.", - category: "color", - params: [ - { - id: "channel", - label: "Channel", - type: "select", - default: "red", - options: [ - { value: "red", label: "Red" }, - { value: "green", label: "Green" }, - { value: "blue", label: "Blue" }, - ], - }, - ], - run: (img, p) => extractChannel(img, str(p, "channel") as RgbChannel), - }, - { - id: "swap-channels-png", - title: "Swap channels PNG", - description: - "Swaps two color channels — a quick way to get unusual coloring.", - category: "color", - params: [ - { - id: "pair", - label: "Channel pair", - type: "select", - default: "r-g", - options: [ - { value: "r-g", label: "Red ↔ Green" }, - { value: "r-b", label: "Red ↔ Blue" }, - { value: "g-b", label: "Green ↔ Blue" }, - ], - }, - ], - run: (img, p) => swapChannels(img, str(p, "pair") as ChannelSwapPair), - }, - { - id: "black-and-white-png", - title: "Black & white threshold PNG", - description: - "Hard binarization by luminance: every pixel becomes black or white.", - category: "color", - params: [ - { - id: "threshold", - label: "Brightness threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - run: (img, p) => thresholdBlackWhite(img, num(p, "threshold")), - }, - { - id: "posterize-png", - title: "Posterize PNG", - description: "Reduces the number of levels per channel — a poster effect.", - category: "color", - params: [ - { - id: "levels", - label: "Levels per channel", - type: "slider", - min: 2, - max: 16, - step: 1, - default: 4, - }, - ], - run: (img, p) => posterize(img, num(p, "levels")), - }, - { - id: "two-colors-png", - title: "Two colors PNG", - description: - "Recolors the image into two chosen colors by luminance threshold.", - category: "color", - params: [ - { - id: "lightColor", - label: "Light areas color", - type: "color", - default: "#ffffff", - }, - { - id: "darkColor", - label: "Dark areas color", - type: "color", - default: "#000000", - }, - { - id: "threshold", - label: "Brightness threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - run: (img, p) => - twoColors( - img, - str(p, "lightColor"), - str(p, "darkColor"), - num(p, "threshold"), - ), - }, - { - id: "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", - params: [ - { - id: "background", - label: "Backdrop color", - type: "color", - default: "#ffffff", - }, - { - id: "quality", - label: "JPEG quality", - type: "slider", - min: 1, - max: 100, - step: 1, - default: 90, - }, - ], - output: { mime: "image/jpeg", ext: "jpg", qualityParamId: "quality" }, - run: (img, p) => flattenOntoColor(img, str(p, "background")), - }, - { - id: "convert-png-to-webp", - title: "Convert PNG to WebP", - description: - "Re-encodes the image into WebP with adjustable quality. Transparency is preserved.", - category: "convert", - params: [ - { - id: "quality", - label: "WebP quality", - type: "slider", - min: 1, - max: 100, - step: 1, - default: 90, - }, - ], - output: { mime: "image/webp", ext: "webp", qualityParamId: "quality" }, - run: (img) => clonePixelImage(img), - }, - { - id: "remove-alpha-channel-png", - title: "Remove alpha channel PNG", - description: - "Composites the image over a white background and saves without transparency.", - category: "alpha", - params: [], - run: (img) => flattenOntoColor(img, "#ffffff"), - }, - { - id: "set-alpha-channel-png", - title: "Set alpha channel PNG", - description: - "Assigns the same opacity to all pixels; colors stay unchanged.", - category: "alpha", - params: [ - { - id: "percent", - label: "Opacity, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 100, - }, - ], - run: (img, p) => setAlphaChannel(img, num(p, "percent")), - }, - { - id: "extract-alpha-mask-png", - title: "Extract alpha mask PNG", - description: "Turns transparency into a black-and-white opaque mask.", - category: "alpha", - params: [], - run: (img) => extractAlphaMask(img), - }, - { - id: "round-corners-png", - title: "Round corners PNG", - description: - "Clips corners by a radius set as a percentage of half the smaller side.", - category: "alpha", - params: [ - { - id: "radius", - label: "Corner radius, %", - type: "slider", - min: 0, - max: 50, - step: 1, - default: 10, - }, - ], - run: (img, p) => roundCorners(img, num(p, "radius")), - }, - { - id: "invert-alpha-png", - title: "Invert alpha PNG", - description: "Opaque areas become transparent and vice versa.", - category: "alpha", - params: [], - run: (img) => invertAlpha(img), - }, - { - id: "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", - params: [ - { - id: "color", - label: "Background color", - type: "color", - default: "#ffffff", - }, - { - id: "tolerance", - label: "Similarity tolerance, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 10, - }, - { - id: "outerOnly", - label: "Outer regions only", - type: "checkbox", - default: true, - }, - { - id: "smooth", - label: "Edge smoothing, passes", - type: "slider", - min: 0, - max: 8, - step: 1, - default: 1, - }, - ], - run: (img, p) => - removeBackground(img, { - color: str(p, "color"), - tolerancePercent: num(p, "tolerance"), - outerOnly: p["outerOnly"] === true, - smoothPasses: num(p, "smooth"), - }), - preview: (img, p) => - backgroundMaskPreview(img, { - color: str(p, "color"), - tolerancePercent: num(p, "tolerance"), - outerOnly: p["outerOnly"] === true, - smoothPasses: num(p, "smooth"), - }), - }, - { - id: "add-stroke-png", - title: "Outline PNG", - description: - "Adds a colored ring outline around the opaque content with the chosen thickness.", - category: "alpha", - params: [ - { - id: "color", - label: "Outline color", - type: "color", - default: "#ff0000", - }, - { - id: "thickness", - label: "Thickness, px", - type: "slider", - min: 1, - max: 10, - step: 1, - default: 3, - }, - ], - run: (img, p) => strokeImage(img, num(p, "thickness"), str(p, "color")), - }, - { - id: "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", - params: [ - { id: "color", label: "Line color", type: "color", default: "#000000" }, - { - id: "thickness", - label: "Line thickness, px", - type: "slider", - min: 1, - max: 5, - step: 1, - default: 1, - }, - ], - run: (img, p) => contourImage(img, num(p, "thickness"), str(p, "color")), - }, - { - id: "make-thicker-png", - title: "Thicken PNG", - description: "Expands opaque areas by the given number of pixels.", - category: "alpha", - params: [ - { - id: "radius", - label: "Amount, px", - type: "slider", - min: 1, - max: 10, - step: 1, - default: 2, - }, - ], - run: (img, p) => dilateImage(img, num(p, "radius")), - }, - { - id: "make-thinner-png", - title: "Thin PNG", - description: - "Shrinks opaque areas — thins the strokes of text and details.", - category: "alpha", - params: [ - { - id: "radius", - label: "Amount, px", - type: "slider", - min: 1, - max: 10, - step: 1, - default: 1, - }, - ], - run: (img, p) => erodeImage(img, num(p, "radius")), - }, - { - id: "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", - params: [ - { - id: "radius", - label: "Feather radius, px", - type: "slider", - min: 1, - max: 20, - step: 1, - default: 3, - }, - ], - run: (img, p) => featherAlpha(img, num(p, "radius")), - }, - { - id: "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", - params: [ - { - id: "radius", - label: "Search radius, px", - type: "slider", - min: 1, - max: 10, - step: 1, - default: 3, - }, - ], - run: (img, p) => defringe(img, num(p, "radius")), - }, - { - id: "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", - params: [ - { - id: "threshold", - label: "Alpha threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - run: (img, p) => hardenAlpha(img, num(p, "threshold")), - }, - { - id: "despeckle-alpha-png", - title: "Despeckle PNG", - description: - "Opening: removes lone semi-transparent pixels and small specks.", - category: "alpha", - params: [ - { - id: "radius", - label: "Cleanup radius, px", - type: "slider", - min: 1, - max: 3, - step: 1, - default: 1, - }, - ], - run: (img, p) => openingImage(img, num(p, "radius")), - }, - { - id: "close-holes-png", - title: "Close holes PNG", - description: "Closing: fills lone transparent dots inside the object.", - category: "alpha", - params: [ - { - id: "radius", - label: "Closing radius, px", - type: "slider", - min: 1, - max: 3, - step: 1, - default: 1, - }, - ], - run: (img, p) => closingImage(img, num(p, "radius")), - }, - { - id: "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", - params: [ - { - id: "targetColor", - label: "Color to remove", - type: "color", - default: "#00ff00", - }, - { - id: "tolerance", - label: "Similarity threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 10, - }, - ], - run: (img, p) => - removeColorToAlpha(img, str(p, "targetColor"), num(p, "tolerance")), - preview: (img, p) => - colorMask(img, str(p, "targetColor"), num(p, "tolerance")), - }, - { - id: "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", - params: [ - { - id: "color", - label: "Color to keep", - type: "color", - default: "#00ff88", - }, - { - id: "tolerance", - label: "Similarity tolerance, %", - type: "slider", - min: 0, - max: 50, - step: 1, - default: 10, - }, - ], - run: (img, p) => extractByColor(img, str(p, "color"), num(p, "tolerance")), - }, - { - id: "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", - sourceMode: "text", - params: [], - resultType: "text", - textToText: (text) => - looksLikePng(base64ToBytes(stripDataUri(text))) - ? "verifyYes" - : "verifyNo", - }, - { - id: "png-info", - title: "PNG info", - description: - "Shows dimensions, alpha presence and the number of unique colors of the uploaded image.", - category: "analyze", - params: [], - resultType: "info", - run: (img) => clonePixelImage(img), - }, - { - id: "create-empty-png", - title: "Create empty PNG", - description: - "Generates a canvas of the chosen size — transparent or filled with color.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 600, - }, - { - id: "transparent", - label: "Transparent", - type: "checkbox", - default: true, - }, - { id: "color", label: "Color", type: "color", default: "#ffffff" }, - ], - generate: (p) => - solidImage( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - p["transparent"] === true ? [0, 0, 0, 0] : hexToRgba(str(p, "color")), - ), - }, - { - id: "single-color-png", - title: "Create solid color PNG", - description: "Generates a rectangle of the given size and color.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 256, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 256, - }, - { id: "color", label: "Color", type: "color", default: "#ff0000" }, - ], - generate: (p) => - solidImage( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - hexToRgba(str(p, "color")), - ), - }, - { - id: "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", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "seed", - label: "Seed", - type: "number", - min: 0, - max: 999999999, - step: 1, - default: 1, - }, - ], - generate: (p) => - noiseImage( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - num(p, "seed"), - ), - }, - { - id: "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", - sourceMode: "none", - domOnly: true, - params: [ - { - id: "text", - label: "Text", - type: "text", - default: "Hello!", - placeholder: "Your text", - }, - { - id: "fontSize", - label: "Font size, px", - type: "slider", - min: 8, - max: 300, - step: 1, - default: 96, - }, - { - id: "font", - label: "Font", - type: "select", - default: "sans", - options: [ - { value: "sans", label: "Sans-serif" }, - { value: "serif", label: "Serif" }, - { value: "mono", label: "Monospace" }, - ], - }, - { id: "bold", label: "Bold", type: "checkbox", default: true }, - { id: "color", label: "Text color", type: "color", default: "#111318" }, - { - id: "transparentBg", - label: "Transparent background", - type: "checkbox", - default: false, - }, - { - id: "backgroundColor", - label: "Background color", - type: "color", - default: "#ffffff", - }, - { - id: "padding", - label: "Padding, px", - type: "slider", - min: 0, - max: 200, - step: 2, - default: 24, - }, - ], - generate: (p) => - renderTextToImage({ - text: str(p, "text"), - fontSize: num(p, "fontSize"), - font: str(p, "font") as TextFont, - bold: bool(p, "bold"), - color: str(p, "color"), - backgroundColor: str(p, "backgroundColor"), - transparentBg: bool(p, "transparentBg"), - padding: num(p, "padding"), - }), - }, - { - id: "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", - sourceMode: "none", - domOnly: true, - params: [ - { id: "emoji", label: "Emoji / symbol", type: "text", default: "😀" }, - { - id: "size", - label: "Size", - type: "slider", - min: 32, - max: 1024, - step: 16, - default: 256, - }, - ], - generate: (p) => renderEmoji(str(p, "emoji"), Math.trunc(num(p, "size"))), - }, - { - id: "placeholder-png", - title: "Create Placeholder PNG", - description: - "Generates a placeholder rectangle with its dimensions printed in the center.", - category: "generate", - sourceMode: "none", - domOnly: true, - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 400, - }, - { - id: "backgroundColor", - label: "Background", - type: "color", - default: "#dfe2e8", - }, - { id: "color", label: "Text color", type: "color", default: "#5c6470" }, - { - id: "showText", - label: "Print dimensions", - type: "checkbox", - default: true, - }, - ], - generate: (p) => { - const w = Math.trunc(num(p, "width")); - const h = Math.trunc(num(p, "height")); - const label = renderTextToImage({ - text: `${w} × ${h}`, - fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)), - font: "sans", - bold: true, - color: str(p, "color"), - backgroundColor: str(p, "backgroundColor"), - transparentBg: false, - padding: 0, - }); - return changeCanvasSize(label, w, h, "center"); - }, - }, - { - id: "linear-gradient-png", - title: "Create gradient PNG", - description: - "Generates a smooth transition between two colors, horizontally or vertically.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 600, - }, - { - id: "fromColor", - label: "Start color", - type: "color", - default: "#000000", - }, - { id: "toColor", label: "End color", type: "color", default: "#ffffff" }, - { - id: "direction", - label: "Direction", - type: "select", - default: "horizontal", - options: [ - { value: "horizontal", label: "Horizontal" }, - { value: "vertical", label: "Vertical" }, - ], - }, - ], - generate: (p) => - gradientImage( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - hexToRgba(str(p, "fromColor")), - hexToRgba(str(p, "toColor")), - str(p, "direction") === "vertical" ? "vertical" : "horizontal", - ), - }, - { - id: "color-spectrum-png", - title: "Color Spectrum PNG", - description: - "Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 1024, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 128, - }, - { - id: "direction", - label: "Direction", - type: "select", - default: "horizontal", - options: [ - { value: "horizontal", label: "Horizontal" }, - { value: "vertical", label: "Vertical" }, - ], - }, - { - id: "saturation", - label: "Saturation, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 100, - }, - { - id: "lightness", - label: "Lightness, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - generate: (p) => - colorSpectrum( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - str(p, "direction") === "vertical" ? "vertical" : "horizontal", - num(p, "saturation"), - num(p, "lightness"), - ), - }, - { - id: "random-colors-png", - title: "Random Color Blocks PNG", - description: - "Fills the canvas with random vivid color blocks. Deterministic by seed.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "blockSize", - label: "Block size, px", - type: "slider", - min: 4, - max: 256, - step: 2, - default: 64, - }, - { - id: "seed", - label: "Seed", - type: "number", - min: 0, - max: 999999999, - step: 1, - default: 7, - }, - ], - generate: (p) => - randomColorBlocks( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - num(p, "blockSize"), - num(p, "seed"), - ), - }, - { - id: "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", - sourceMode: "none", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 5000, - step: 1, - default: 512, - }, - { - id: "cols", - label: "Columns", - type: "slider", - min: 1, - max: 64, - step: 1, - default: 8, - }, - { - id: "rows", - label: "Rows", - type: "slider", - min: 1, - max: 64, - step: 1, - default: 8, - }, - { - id: "lineWidth", - label: "Line width, px", - type: "slider", - min: 1, - max: 40, - step: 1, - default: 2, - }, - { id: "color", label: "Line color", type: "color", default: "#111318" }, - { - id: "transparentBg", - label: "Transparent background", - type: "checkbox", - default: true, - }, - ], - generate: (p) => - drawGrid( - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - num(p, "cols"), - num(p, "rows"), - num(p, "lineWidth"), - str(p, "color"), - bool(p, "transparentBg"), - ), - }, - { - id: "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", - sourceMode: "none", - params: [ - { - id: "width", - label: "Size", - type: "slider", - min: 128, - max: 1024, - step: 16, - default: 512, - }, - { - id: "lightness", - label: "Lightness, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 50, - }, - ], - generate: (p) => - renderWheel(Math.trunc(num(p, "width")), num(p, "lightness")), - }, - { - id: "complementary-png", - title: "Complementary Palette PNG", - description: - "Two opposite colors on the color wheel — the base and its complement.", - category: "generate", - sourceMode: "none", - params: paletteParams("#2563eb"), - generate: (p) => - renderSwatches( - complementarySet(str(p, "baseColor")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "triadic-png", - title: "Triadic Palette PNG", - description: "Three colors evenly spaced 120° apart on the color wheel.", - category: "generate", - sourceMode: "none", - params: paletteParams("#ff0000"), - generate: (p) => - renderSwatches( - triadicSet(str(p, "baseColor")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "tetradic-png", - title: "Tetradic Palette PNG", - description: - "Four colors in two complementary pairs, 90° apart on the wheel.", - category: "generate", - sourceMode: "none", - params: paletteParams("#8000ff"), - generate: (p) => - renderSwatches( - tetradicSet(str(p, "baseColor")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "analogous-png", - title: "Analogous Palette PNG", - description: - "Neighboring hues around the base color — calm, related color scheme.", - category: "generate", - sourceMode: "none", - params: [ - ...paletteParams("#22c55e"), - { - id: "spread", - label: "Hue spread, °", - type: "slider", - min: 10, - max: 90, - step: 5, - default: 30, - }, - { - id: "count", - label: "Colors", - type: "slider", - min: 3, - max: 9, - step: 1, - default: 5, - }, - ], - generate: (p) => - renderSwatches( - analogousSet(str(p, "baseColor"), num(p, "spread"), num(p, "count")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "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", - sourceMode: "none", - params: [ - ...paletteParams("#0ea5e9"), - { - id: "count", - label: "Colors", - type: "slider", - min: 2, - max: 9, - step: 1, - default: 5, - }, - { - id: "range", - label: "Lightness range, %", - type: "slider", - min: 10, - max: 90, - step: 5, - default: 40, - }, - ], - generate: (p) => - renderSwatches( - monochromaticSet(str(p, "baseColor"), num(p, "count"), num(p, "range")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "shades-png", - title: "Shade Ramp PNG", - description: "A ramp of the base color getting darker step by step.", - category: "generate", - sourceMode: "none", - params: [ - ...paletteParams("#f59e0b"), - { - id: "count", - label: "Colors", - type: "slider", - min: 2, - max: 9, - step: 1, - default: 5, - }, - { - id: "depth", - label: "Darkening depth, %", - type: "slider", - min: 10, - max: 90, - step: 5, - default: 50, - }, - ], - generate: (p) => - renderSwatches( - shadeSet(str(p, "baseColor"), num(p, "count"), num(p, "depth")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "mix-colors-png", - title: "Mix Colors PNG", - description: - "Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "colors", - label: "Colors (comma-separated hex)", - type: "text", - default: "#ff0000,#00ff00,#0000ff", - }, - { - id: "width", - label: "Width", - type: "slider", - min: 128, - max: 1024, - step: 16, - default: 512, - }, - ], - generate: (p) => - renderSwatches( - [mixColors(parseHexList(str(p, "colors")))], - num(p, "width"), - "strip", - ), - }, - { - id: "blend-two-png", - title: "Blend Two Colors PNG", - description: "A continuous horizontal gradient between two colors.", - category: "generate", - sourceMode: "none", - params: [ - { id: "colorA", label: "Color A", type: "color", default: "#000000" }, - { id: "colorB", label: "Color B", type: "color", default: "#ffffff" }, - { - id: "width", - label: "Width", - type: "slider", - min: 128, - max: 1024, - step: 16, - default: 512, - }, - ], - generate: (p) => - renderBlend(str(p, "colorA"), str(p, "colorB"), num(p, "width")), - }, - { - id: "step-colors-png", - title: "Color Steps PNG", - description: "A discrete set of evenly spaced steps between two colors.", - category: "generate", - sourceMode: "none", - params: [ - { id: "colorA", label: "Color A", type: "color", default: "#000000" }, - { id: "colorB", label: "Color B", type: "color", default: "#ffffff" }, - { - id: "steps", - label: "Steps", - type: "slider", - min: 2, - max: 12, - step: 1, - default: 6, - }, - ...paletteParams("#808080").filter((q) => q.id !== "baseColor"), - ], - generate: (p) => - renderSwatches( - stepColors(str(p, "colorA"), str(p, "colorB"), num(p, "steps")), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "sort-colors-png", - title: "Sort Colors PNG", - description: - "Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.", - category: "generate", - sourceMode: "none", - params: [ - { - id: "colors", - label: "Colors (comma-separated hex)", - type: "text", - default: "#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff", - }, - { - id: "order", - label: "Sort by", - type: "select", - default: "hue", - options: [ - { value: "hue", label: "Hue" }, - { value: "luma", label: "Brightness" }, - { value: "sat", label: "Saturation" }, - ], - }, - ...paletteParams("#ffffff").filter((q) => q.id !== "baseColor"), - ], - generate: (p) => - renderSwatches( - sortPalette(parseHexList(str(p, "colors")), str(p, "order") as SortKey), - num(p, "width"), - str(p, "layout") as "strip" | "grid", - ), - }, - { - id: "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, - params: [ - { - id: "text", - label: "Text", - type: "text", - default: "Hello!", - placeholder: "Your text", - }, - { - id: "fontSize", - label: "Font size, px", - type: "slider", - min: 8, - max: 200, - step: 1, - default: 48, - }, - { id: "color", label: "Text color", type: "color", default: "#ffffff" }, - { - id: "font", - label: "Font", - type: "select", - default: "sans", - options: [ - { value: "sans", label: "Sans-serif" }, - { value: "serif", label: "Serif" }, - { value: "mono", label: "Monospace" }, - ], - }, - { id: "bold", label: "Bold", type: "checkbox", default: true }, - { - id: "position", - label: "Position", - type: "select", - default: "bottom-right", - options: [ - { value: "top-left", label: "Top left" }, - { value: "top-center", label: "Top center" }, - { value: "top-right", label: "Top right" }, - { value: "middle-left", label: "Middle left" }, - { value: "center", label: "Center" }, - { value: "middle-right", label: "Middle right" }, - { value: "bottom-left", label: "Bottom left" }, - { value: "bottom-center", label: "Bottom center" }, - { value: "bottom-right", label: "Bottom right" }, - ], - }, - { - id: "margin", - label: "Margin, px", - type: "slider", - min: 0, - max: 200, - step: 1, - default: 24, - }, - { id: "plate", label: "Backing plate", type: "checkbox", default: false }, - { - id: "plateColor", - label: "Plate color", - type: "color", - default: "#000000", - }, - { - id: "plateOpacity", - label: "Plate opacity, %", - type: "slider", - min: 0, - max: 100, - step: 5, - default: 60, - }, - ], - run: (img, p) => - drawTextBlock(img, { - text: str(p, "text"), - fontSize: num(p, "fontSize"), - font: str(p, "font") as TextFont, - bold: bool(p, "bold"), - color: str(p, "color"), - opacityPercent: 100, - position: str(p, "position") as Position9, - margin: num(p, "margin"), - plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined, - plateOpacityPercent: num(p, "plateOpacity"), - }), - }, - { - id: "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, - params: [ - { - id: "format", - label: "Format", - type: "text", - default: "YYYY-MM-DD", - placeholder: "YYYY-MM-DD hh:mm", - }, - { - id: "fontSize", - label: "Font size, px", - type: "slider", - min: 8, - max: 200, - step: 1, - default: 32, - }, - { id: "color", label: "Text color", type: "color", default: "#ffffff" }, - { - id: "font", - label: "Font", - type: "select", - default: "mono", - options: [ - { value: "sans", label: "Sans-serif" }, - { value: "serif", label: "Serif" }, - { value: "mono", label: "Monospace" }, - ], - }, - { id: "bold", label: "Bold", type: "checkbox", default: false }, - { - id: "position", - label: "Position", - type: "select", - default: "bottom-right", - options: [ - { value: "top-left", label: "Top left" }, - { value: "top-center", label: "Top center" }, - { value: "top-right", label: "Top right" }, - { value: "middle-left", label: "Middle left" }, - { value: "center", label: "Center" }, - { value: "middle-right", label: "Middle right" }, - { value: "bottom-left", label: "Bottom left" }, - { value: "bottom-center", label: "Bottom center" }, - { value: "bottom-right", label: "Bottom right" }, - ], - }, - { - id: "margin", - label: "Margin, px", - type: "slider", - min: 0, - max: 200, - step: 1, - default: 20, - }, - { id: "plate", label: "Backing plate", type: "checkbox", default: true }, - { - id: "plateColor", - label: "Plate color", - type: "color", - default: "#000000", - }, - { - id: "plateOpacity", - label: "Plate opacity, %", - type: "slider", - min: 0, - max: 100, - step: 5, - default: 55, - }, - ], - run: (img, p) => - drawTextBlock(img, { - text: formatStamp(new Date(), str(p, "format")), - fontSize: num(p, "fontSize"), - font: str(p, "font") as TextFont, - bold: bool(p, "bold"), - color: str(p, "color"), - opacityPercent: 100, - position: str(p, "position") as Position9, - margin: num(p, "margin"), - plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined, - plateOpacityPercent: num(p, "plateOpacity"), - }), - }, - { - id: "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, - params: [ - { - id: "text", - label: "Text", - type: "text", - default: "DRAFT", - placeholder: "Watermark text", - }, - { - id: "fontSize", - label: "Font size, px", - type: "slider", - min: 12, - max: 160, - step: 1, - default: 56, - }, - { id: "color", label: "Text color", type: "color", default: "#ffffff" }, - { - id: "opacity", - label: "Opacity, %", - type: "slider", - min: 5, - max: 100, - step: 5, - default: 30, - }, - { - id: "angle", - label: "Angle, °", - type: "slider", - min: -90, - max: 90, - step: 1, - default: -30, - }, - { - id: "stepX", - label: "Step X, px", - type: "slider", - min: 40, - max: 600, - step: 10, - default: 220, - }, - { - id: "stepY", - label: "Step Y, px", - type: "slider", - min: 40, - max: 600, - step: 10, - default: 180, - }, - { - id: "font", - label: "Font", - type: "select", - default: "sans", - options: [ - { value: "sans", label: "Sans-serif" }, - { value: "serif", label: "Serif" }, - { value: "mono", label: "Monospace" }, - ], - }, - { id: "bold", label: "Bold", type: "checkbox", default: true }, - ], - run: (img, p) => - drawTextTile(img, { - text: str(p, "text"), - fontSize: num(p, "fontSize"), - font: str(p, "font") as TextFont, - bold: bool(p, "bold"), - color: str(p, "color"), - opacityPercent: num(p, "opacity"), - stepX: num(p, "stepX"), - stepY: num(p, "stepY"), - angleDeg: num(p, "angle"), - }), - }, - { - id: "watermark-image-png", - title: "Watermark Image PNG", - description: - "Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.", - category: "alpha", - domOnly: true, - needsOverlaySource: true, - params: [ - { - id: "scale", - label: "Mark width, % of canvas", - type: "slider", - min: 5, - max: 100, - step: 1, - default: 30, - }, - { - id: "opacity", - label: "Opacity, %", - type: "slider", - min: 5, - max: 100, - step: 5, - default: 60, - }, - { - id: "position", - label: "Position", - type: "select", - default: "bottom-right", - options: [ - { value: "top-left", label: "Top left" }, - { value: "top-center", label: "Top center" }, - { value: "top-right", label: "Top right" }, - { value: "middle-left", label: "Middle left" }, - { value: "center", label: "Center" }, - { value: "middle-right", label: "Middle right" }, - { value: "bottom-left", label: "Bottom left" }, - { value: "bottom-center", label: "Bottom center" }, - { value: "bottom-right", label: "Bottom right" }, - ], - }, - { - id: "margin", - label: "Margin, px", - type: "slider", - min: 0, - max: 200, - step: 1, - default: 24, - }, - ], - run: (img, p) => { - const mark = getOverlay(); - if (!mark) throw new ToolError("errors.noWatermark"); - return drawImageWatermark(img, { - mark, - scalePercent: num(p, "scale"), - opacityPercent: num(p, "opacity"), - position: str(p, "position") as Position9, - margin: num(p, "margin"), - }); - }, - }, - { - id: "png-is-grayscale", - title: "Check: is PNG grayscale?", - description: "Reports whether the image consists only of shades of gray.", - category: "analyze", - params: [], - resultType: "text", - toText: (img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo"), - }, - { - id: "skew-png", - title: "Skew PNG", - description: - "Shifts content horizontally and vertically — a perspective effect.", - category: "geometry", - params: [ - { - id: "degX", - label: "Skew X, °", - type: "slider", - min: -80, - max: 80, - step: 1, - default: 0, - }, - { - id: "degY", - label: "Skew Y, °", - type: "slider", - min: -80, - max: 80, - step: 1, - default: 0, - }, - ], - run: (img, p) => skewImage(img, num(p, "degX"), num(p, "degY")), - }, - { - id: "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", - params: [ - { - id: "angle", - label: "Angle, °", - type: "slider", - min: -180, - max: 180, - step: 1, - default: 15, - }, - ], - run: (img, p) => rotateFreeImage(img, num(p, "angle")), - }, - { - id: "zoom-png", - title: "Zoom PNG", - description: - "Magnifies content toward the center. The canvas keeps its size — edges are cropped.", - category: "geometry", - params: [ - { - id: "scale", - label: "Scale, %", - type: "slider", - min: 100, - max: 500, - step: 10, - default: 200, - }, - ], - run: (img, p) => zoomImage(img, num(p, "scale")), - }, - { - id: "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", - params: [ - { - id: "threshold", - label: "Alpha threshold", - type: "slider", - min: 0, - max: 254, - step: 1, - default: 0, - }, - ], - run: (img, p) => trimToContent(img, num(p, "threshold")), - }, - { - id: "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", - params: [ - { - id: "width", - label: "Width", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 800, - }, - { - id: "height", - label: "Height", - type: "number", - min: 1, - max: 20000, - step: 1, - default: 600, - }, - { - id: "anchor", - label: "Anchor", - type: "select", - default: "center", - options: [ - { value: "top-left", label: "Top left" }, - { value: "top-center", label: "Top center" }, - { value: "top-right", label: "Top right" }, - { value: "middle-left", label: "Middle left" }, - { value: "center", label: "Center" }, - { value: "middle-right", label: "Middle right" }, - { value: "bottom-left", label: "Bottom left" }, - { value: "bottom-center", label: "Bottom center" }, - { value: "bottom-right", label: "Bottom right" }, - ], - }, - ], - run: (img, p) => - changeCanvasSize( - img, - Math.trunc(num(p, "width")), - Math.trunc(num(p, "height")), - str(p, "anchor") as Anchor9, - ), - }, - { - id: "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", - params: [ - { - id: "ratio", - label: "Target ratio", - type: "select", - default: "1:1", - options: [ - { value: "1:1", label: "1:1" }, - { value: "4:3", label: "4:3" }, - { value: "3:4", label: "3:4" }, - { value: "3:2", label: "3:2" }, - { value: "2:3", label: "2:3" }, - { value: "16:9", label: "16:9" }, - { value: "9:16", label: "9:16" }, - ], - }, - { - id: "mode", - label: "Mode", - type: "select", - default: "crop", - options: [ - { value: "crop", label: "Crop to fill" }, - { value: "pad", label: "Pad to fit" }, - ], - }, - ], - run: (img, p) => { - const [rw, rh] = str(p, "ratio").split(":").map(Number); - const ratio = rw / rh; - return str(p, "mode") === "pad" - ? padToRatio(img, ratio) - : cropToRatio(img, ratio); - }, - }, - { - id: "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", - params: [ - { - id: "target", - label: "Target orientation", - type: "select", - default: "portrait", - options: [ - { value: "portrait", label: "Portrait" }, - { value: "landscape", label: "Landscape" }, - ], - }, - ], - run: (img, p) => - forceOrientation( - img, - str(p, "target") === "landscape" ? "landscape" : "portrait", - ), - }, - { - id: "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", - params: [ - { - id: "axis", - label: "Mirror line", - type: "select", - default: "vertical", - options: [ - { value: "vertical", label: "Vertical (double width)" }, - { value: "horizontal", label: "Horizontal (double height)" }, - ], - }, - { - id: "keepSide", - label: "Keep side", - type: "select", - default: "left", - options: [ - { value: "left", label: "Left" }, - { value: "right", label: "Right" }, - { value: "top", label: "Top" }, - { value: "bottom", label: "Bottom" }, - ], - }, - ], - run: (img, p) => - symmetricCopy( - img, - str(p, "axis") === "horizontal" ? "horizontal" : "vertical", - str(p, "keepSide") as "left" | "right" | "top" | "bottom", - ), - }, - { - id: "shift-png", - title: "Shift PNG", - description: "Moves content by the given X and Y offset.", - category: "geometry", - params: [ - { - id: "offsetX", - label: "Offset X, px", - type: "number", - min: -5000, - max: 5000, - step: 1, - default: 0, - }, - { - id: "offsetY", - label: "Offset Y, px", - type: "number", - min: -5000, - max: 5000, - step: 1, - default: 0, - }, - { - id: "color", - label: "Background color", - type: "color", - default: "#ffffff", - }, - ], - run: (img, p) => - transformImage( - img, - [ - 1, - 0, - 0, - 1, - -Math.trunc(num(p, "offsetX")), - -Math.trunc(num(p, "offsetY")), - ], - img.width, - img.height, - str(p, "color"), - ), - }, - { - id: "vignette-png", - title: "Vignette PNG", - description: - "Smoothly darkens the edges of the image, leaving the center untouched.", - category: "filters", - params: [ - { - id: "strength", - label: "Darkening strength, %", - type: "slider", - min: 0, - max: 100, - step: 5, - default: 50, - }, - ], - run: (img, p) => vignette(img, num(p, "strength")), - }, - { - id: "pixelate-png", - title: "Pixelate PNG", - description: - "Averages every blockSize×blockSize area into one color — classic mosaic.", - category: "filters", - params: [ - { - id: "blockSize", - label: "Block size, px", - type: "slider", - min: 2, - max: 64, - step: 1, - default: 8, - }, - ], - run: (img, p) => pixelate(img, num(p, "blockSize")), - }, - { - id: "randomize-pixels-png", - title: "Randomize Pixels PNG", - description: - "Shuffles blocks of the image between positions. Same seed gives the same arrangement.", - category: "filters", - params: [ - { - id: "blockSize", - label: "Block size, px", - type: "slider", - min: 1, - max: 64, - step: 1, - default: 8, - }, - { - id: "seed", - label: "Seed", - type: "number", - min: 0, - max: 999999, - step: 1, - default: 42, - }, - ], - run: (img, p) => shuffleBlocks(img, num(p, "blockSize"), num(p, "seed")), - }, - { - id: "add-noise-png", - title: "Add Noise to PNG", - description: - "Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.", - category: "filters", - params: [ - { - id: "amount", - label: "Amount, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 25, - }, - { - id: "mode", - label: "Noise type", - type: "select", - default: "mono", - options: [ - { value: "mono", label: "Monochrome grain" }, - { value: "color", label: "Color noise" }, - ], - }, - { - id: "seed", - label: "Seed", - type: "number", - min: 0, - max: 999999, - step: 1, - default: 1234, - }, - ], - run: (img, p) => - addNoise( - img, - num(p, "amount"), - str(p, "mode") === "color" ? "color" : "mono", - num(p, "seed"), - ), - }, - { - id: "silhouette-png", - title: "Silhouette PNG", - description: - "Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.", - category: "filters", - params: [ - { - id: "color", - label: "Silhouette color", - type: "color", - default: "#111318", - }, - { - id: "threshold", - label: "Visibility threshold, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 10, - }, - ], - run: (img, p) => - silhouette(img, str(p, "color"), num(p, "threshold") * 2.55), - }, - { - id: "jpeg-artifacts-png", - title: "JPEG artifacts", - description: - "Simulates low-quality JPEG re-compression — visible blocks and smeared colors.", - category: "filters", - params: [ - { - id: "quality", - label: "JPEG quality", - type: "slider", - min: 1, - max: 50, - step: 1, - default: 10, - }, - ], - run: (img, p) => jpegRoundtrip(img, num(p, "quality")), - }, - { - id: "gamma-png", - title: "Gamma correction PNG", - description: - "Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.", - category: "color", - params: [ - { - id: "value", - label: "Gamma", - type: "slider", - min: 0.1, - max: 3, - step: 0.05, - default: 1, - }, - ], - run: (img, p) => gammaCorrection(img, num(p, "value")), - }, - { - id: "auto-contrast-png", - title: "Auto contrast PNG", - description: - "Stretches each channel's range across the full available brightness range.", - category: "color", - params: [], - run: (img) => autoContrast(img), - }, - { - id: "temperature-png", - title: "Temperature PNG", - description: - "Positive values make the image warmer (more orange), negative ones cooler (more blue).", - category: "color", - params: [ - { - id: "percent", - label: "Temperature", - type: "slider", - min: -100, - max: 100, - step: 1, - default: 0, - }, - ], - run: (img, p) => temperature(img, num(p, "percent")), - }, - { - id: "tint-png", - title: "Tint PNG", - description: - "Multiplies color channels by the chosen tint with the given strength.", - category: "color", - params: [ - { id: "color", label: "Tint color", type: "color", default: "#ffb060" }, - { - id: "strength", - label: "Strength, %", - type: "slider", - min: 0, - max: 100, - step: 1, - default: 30, - }, - ], - run: (img, p) => tint(img, str(p, "color"), num(p, "strength")), - }, - { - id: "quantize-png", - title: "Quantize PNG", - description: - "Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.", - category: "color", - params: [ - { - id: "colors", - label: "Colors (k)", - type: "slider", - min: 2, - max: 64, - step: 1, - default: 16, - }, - ], - run: (img, p) => quantizeImage(img, num(p, "colors")).image, - }, - { - id: "decrease-color-count-png", - title: "Decrease Color Count PNG", - description: - "Same median-cut engine with fixed power-of-two presets — quick way to drop to 2–256 colors.", - category: "color", - params: [ - { - id: "maxColors", - label: "Max colors", - type: "select", - default: "16", - options: [ - { value: "2", label: "2" }, - { value: "4", label: "4" }, - { value: "8", label: "8" }, - { value: "16", label: "16" }, - { value: "32", label: "32" }, - { value: "64", label: "64" }, - { value: "128", label: "128" }, - { value: "256", label: "256" }, - ], - }, - ], - run: (img, p) => quantizeImage(img, num(p, "maxColors")).image, - }, - { - id: "custom-palette-png", - title: "Custom Palette PNG", - description: - "Maps every pixel to the nearest color from your comma-separated hex list.", - category: "color", - params: [ - { - id: "colors", - label: "Palette (comma-separated hex)", - type: "text", - default: "#000000,#ffffff", - }, - ], - run: (img, p) => mapToNearest(img, parseHexList(str(p, "colors"))), - }, - { - id: "dithering-png", - title: "Dithering PNG", - description: - "Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.", - category: "color", - params: [ - { - id: "colors", - label: "Colors (k)", - type: "slider", - min: 2, - max: 16, - step: 1, - default: 4, - }, - { - id: "pattern", - label: "Pattern", - type: "select", - default: "floyd-steinberg", - options: [ - { value: "floyd-steinberg", label: "Floyd–Steinberg" }, - { value: "bayer", label: "Bayer 4×4" }, - ], - }, - ], - run: (img, p) => - ditherImage( - img, - num(p, "colors"), - str(p, "pattern") === "bayer" ? "bayer" : "floyd-steinberg", - ), - }, - { - id: "compress-png", - title: "Compress PNG", - description: - "Shrinks the PNG by reducing its palette to a preset level. Honest trade-off: fewer colors = smaller file.", - category: "convert", - params: [ - { - id: "level", - label: "Compression level", - type: "select", - default: "balanced", - options: [ - { value: "light", label: "Light (192 colors)" }, - { value: "balanced", label: "Balanced (96 colors)" }, - { value: "strong", label: "Strong (44 colors)" }, - { value: "extreme", label: "Extreme (16 colors)" }, - ], - }, - ], - run: (img, p) => { - const level = str(p, "level") as CompressionLevel; - const k = COMPRESSION_LEVELS[level] ?? COMPRESSION_LEVELS.balanced; - return quantizeImage(img, k).image; - }, - }, - { - id: "reduce-to-size-png", - title: "Reduce PNG to Size", - description: - "Binary-searches the palette size until the encoded PNG fits the target KB. Best effort: if even 2 colors exceed the target, returns the 2-color version.", - category: "convert", - domOnly: true, - params: [ - { - id: "targetKB", - label: "Target size, KB", - type: "slider", - min: 5, - max: 2000, - step: 5, - default: 100, - }, - { - id: "maxColors", - label: "Max colors to try", - type: "slider", - min: 2, - max: 256, - step: 1, - default: 256, - }, - ], - run: async (img, p) => { - const targetBytes = num(p, "targetKB") * 1024; - const maxK = num(p, "maxColors"); - const encodeSize = async (k: number): Promise => { - const blob = await encode(quantizeImage(img, k).image, "image/png"); - return blob.size; - }; - const k = await findMaxColorsWithin(targetBytes, maxK, encodeSize); - return quantizeImage(img, k).image; - }, - }, - { - id: "png-file-size", - title: "PNG File Size", - description: - "Encodes the image as PNG and reports the resulting file size.", - category: "analyze", - domOnly: true, - params: [], - resultType: "text", - toText: async (img) => { - const blob = await encode(img, "image/png"); - const kb = blob.size / 1024; - const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1); - return t("tools.png-file-size.results.line", { kb: kbText }); - }, - }, - { - id: "svg-to-png", - title: "SVG to PNG", - description: - "Decodes SVG markup into a raster image. Paste the SVG code on the left.", - category: "convert", - sourceMode: "text", - params: [ - { - id: "width", - label: "Result width, px", - type: "number", - min: 1, - max: 10000, - step: 1, - default: 512, - }, - ], - runFromText: (text, p) => decodeSvgText(text, Math.trunc(num(p, "width"))), - run: (img) => clonePixelImage(img), - }, - { - id: "png-is-transparent", - title: "Check: is PNG transparent?", - description: - "Reports whether the image contains transparent or semi-transparent pixels.", - category: "analyze", - params: [], - resultType: "text", - toText: (img) => - hasTransparency(img) ? "transparentYes" : "transparentNo", - }, - { - id: "png-orientation", - title: "PNG orientation", - description: "Reports whether it is portrait, landscape or square.", - category: "analyze", - params: [], - resultType: "text", - toText: (img) => { - switch (orientationOf(img)) { - case "portrait": - return "orientationPortrait"; - case "landscape": - return "orientationLandscape"; - default: - return "orientationSquare"; - } - }, - }, + ..._color.slice(0, 6), + ..._analyze.slice(0, 6), + ..._convert.slice(0, 16), + ..._geometry.slice(0, 8), + ..._alpha.slice(0, 4), + ..._geometry.slice(8, 9), + ..._filters.slice(0, 2), + ..._color.slice(6, 17), + ..._convert.slice(16, 18), + ..._alpha.slice(4, 20), + ..._analyze.slice(6, 9), + ..._generate, + ..._text, + ..._alpha.slice(20, 21), + ..._analyze.slice(9, 10), + ..._geometry.slice(9, 18), + ..._filters.slice(2, 8), + ..._color.slice(17, 25), + ..._convert.slice(18, 20), + ..._analyze.slice(10, 11), + ..._convert.slice(20, 21), + ..._analyze.slice(11, 13), ]; import { TOOL_ICONS } from "./tools/tool-icons"; diff --git a/web/src/lib/registry/alpha.ts b/web/src/lib/registry/alpha.ts new file mode 100644 index 0000000..6468dd3 --- /dev/null +++ b/web/src/lib/registry/alpha.ts @@ -0,0 +1,686 @@ +import type { ToolEntry } from "../registry"; +import { ToolError } from "../core/errors"; +import { field, toolSchema } from "../registry-schema"; +import { + colorMask, + extractAlphaMask, + flattenOntoColor, + hardenAlpha, + invertAlpha, + removeColorToAlpha, + roundCorners, + setAlphaChannel, +} from "../core/alpha"; +import { backgroundMaskPreview, removeBackground } from "../core/background"; +import { + closingImage, + contourImage, + dilateImage, + erodeImage, + openingImage, + strokeImage, +} from "../core/morphology"; +import { featherAlpha, defringe } from "../core/pixel-fx"; +import { drawImageWatermark } from "../core/domText"; +import { getOverlay } from "../tools/overlay-store.svelte"; +import { + renderShape, + circleTest, + boxTest, + starTest, + wavyTest, +} from "../core/shapes"; +import type { Position9 } from "../core/textdraw"; +import { num, str } from "../registry-helpers"; + +interface AddStrokeParams { + color: string; + thickness: number; +} + +const addStrokeSchema = toolSchema({ + color: field.color({ default: "#ff0000" }), + thickness: field.slider({ min: 1, max: 10, step: 1, default: 3 }), +}); + +export function alphaEntries(): ToolEntry[] { + return [ + { + id: "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", + params: [ + { + id: "size", + label: "Diameter, % of smaller side", + type: "slider", + min: 20, + max: 100, + step: 1, + default: 100, + }, + { + id: "offsetX", + label: "Offset X, %", + type: "slider", + min: -50, + max: 50, + step: 1, + default: 0, + }, + { + id: "offsetY", + label: "Offset Y, %", + type: "slider", + min: -50, + max: 50, + step: 1, + default: 0, + }, + ], + run: (img, p) => + renderShape( + img, + circleTest(num(p, "size") / 200), + num(p, "offsetX") / 100, + num(p, "offsetY") / 100, + ), + }, + { + id: "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", + params: [ + { + id: "widthPct", + label: "Width, % of smaller side", + type: "slider", + min: 10, + max: 100, + step: 1, + default: 100, + }, + { + id: "heightPct", + label: "Height, % of smaller side", + type: "slider", + min: 10, + max: 100, + step: 1, + default: 100, + }, + { + id: "offsetX", + label: "Offset X, %", + type: "slider", + min: -50, + max: 50, + step: 1, + default: 0, + }, + { + id: "offsetY", + label: "Offset Y, %", + type: "slider", + min: -50, + max: 50, + step: 1, + default: 0, + }, + ], + run: (img, p) => + renderShape( + img, + boxTest(num(p, "widthPct") / 200, num(p, "heightPct") / 200), + num(p, "offsetX") / 100, + num(p, "offsetY") / 100, + ), + }, + { + id: "star-mask-png", + title: "Star Mask PNG", + description: + "Cuts the image into an n-pointed star with adjustable inner radius and rotation.", + category: "alpha", + params: [ + { + id: "points", + label: "Points", + type: "slider", + min: 3, + max: 12, + step: 1, + default: 5, + }, + { + id: "innerRadius", + label: "Inner radius, %", + type: "slider", + min: 10, + max: 90, + step: 1, + default: 45, + }, + { + id: "size", + label: "Outer radius, % of smaller side", + type: "slider", + min: 20, + max: 100, + step: 1, + default: 100, + }, + { + id: "rotation", + label: "Rotation, °", + type: "slider", + min: -180, + max: 180, + step: 1, + default: 0, + }, + { + id: "offsetX", + label: "Offset X, %", + type: "slider", + min: -50, + max: 50, + step: 1, + default: 0, + }, + { + id: "offsetY", + label: "Offset Y, %", + type: "slider", + min: -50, + max: 50, + step: 1, + default: 0, + }, + ], + run: (img, p) => + renderShape( + img, + starTest( + num(p, "points"), + num(p, "innerRadius") / 100, + num(p, "size") / 200, + num(p, "rotation"), + ), + num(p, "offsetX") / 100, + num(p, "offsetY") / 100, + ), + }, + { + id: "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", + params: [ + { + id: "size", + label: "Base radius, % of smaller side", + type: "slider", + min: 20, + max: 100, + step: 1, + default: 90, + }, + { + id: "amplitude", + label: "Wave amplitude, %", + type: "slider", + min: 2, + max: 30, + step: 1, + default: 8, + }, + { + id: "waves", + label: "Waves count", + type: "slider", + min: 3, + max: 24, + step: 1, + default: 8, + }, + { + id: "phase", + label: "Phase, °", + type: "slider", + min: 0, + max: 360, + step: 1, + default: 0, + }, + { + id: "offsetX", + label: "Offset X, %", + type: "slider", + min: -50, + max: 50, + step: 1, + default: 0, + }, + { + id: "offsetY", + label: "Offset Y, %", + type: "slider", + min: -50, + max: 50, + step: 1, + default: 0, + }, + ], + run: (img, p) => + renderShape( + img, + wavyTest( + num(p, "size") / 200, + num(p, "amplitude") / 200, + num(p, "waves"), + num(p, "phase"), + ), + num(p, "offsetX") / 100, + num(p, "offsetY") / 100, + ), + }, + { + id: "remove-alpha-channel-png", + title: "Remove alpha channel PNG", + description: + "Composites the image over a white background and saves without transparency.", + category: "alpha", + params: [], + run: (img) => flattenOntoColor(img, "#ffffff"), + }, + { + id: "set-alpha-channel-png", + title: "Set alpha channel PNG", + description: + "Assigns the same opacity to all pixels; colors stay unchanged.", + category: "alpha", + params: [ + { + id: "percent", + label: "Opacity, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 100, + }, + ], + run: (img, p) => setAlphaChannel(img, num(p, "percent")), + }, + { + id: "extract-alpha-mask-png", + title: "Extract alpha mask PNG", + description: "Turns transparency into a black-and-white opaque mask.", + category: "alpha", + params: [], + run: (img) => extractAlphaMask(img), + }, + { + id: "round-corners-png", + title: "Round corners PNG", + description: + "Clips corners by a radius set as a percentage of half the smaller side.", + category: "alpha", + params: [ + { + id: "radius", + label: "Corner radius, %", + type: "slider", + min: 0, + max: 50, + step: 1, + default: 10, + }, + ], + run: (img, p) => roundCorners(img, num(p, "radius")), + }, + { + id: "invert-alpha-png", + title: "Invert alpha PNG", + description: "Opaque areas become transparent and vice versa.", + category: "alpha", + params: [], + run: (img) => invertAlpha(img), + }, + { + id: "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", + params: [ + { + id: "color", + label: "Background color", + type: "color", + default: "#ffffff", + }, + { + id: "tolerance", + label: "Similarity tolerance, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 10, + }, + { + id: "outerOnly", + label: "Outer regions only", + type: "checkbox", + default: true, + }, + { + id: "smooth", + label: "Edge smoothing, passes", + type: "slider", + min: 0, + max: 8, + step: 1, + default: 1, + }, + ], + run: (img, p) => + removeBackground(img, { + color: str(p, "color"), + tolerancePercent: num(p, "tolerance"), + outerOnly: p["outerOnly"] === true, + smoothPasses: num(p, "smooth"), + }), + preview: (img, p) => + backgroundMaskPreview(img, { + color: str(p, "color"), + tolerancePercent: num(p, "tolerance"), + outerOnly: p["outerOnly"] === true, + smoothPasses: num(p, "smooth"), + }), + }, + { + id: "add-stroke-png", + title: "Outline PNG", + description: + "Adds a colored ring outline around the opaque content with the chosen thickness.", + category: "alpha", + schema: addStrokeSchema, + params: [ + { + id: "color", + label: "Outline color", + type: "color", + default: "#ff0000", + }, + { + id: "thickness", + label: "Thickness, px", + type: "slider", + min: 1, + max: 10, + step: 1, + default: 3, + }, + ], + run: (img, p) => strokeImage(img, num(p, "thickness"), str(p, "color")), + }, + { + id: "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", + params: [ + { + id: "color", + label: "Line color", + type: "color", + default: "#000000", + }, + { + id: "thickness", + label: "Line thickness, px", + type: "slider", + min: 1, + max: 5, + step: 1, + default: 1, + }, + ], + run: (img, p) => contourImage(img, num(p, "thickness"), str(p, "color")), + }, + { + id: "make-thicker-png", + title: "Thicken PNG", + description: "Expands opaque areas by the given number of pixels.", + category: "alpha", + params: [ + { + id: "radius", + label: "Amount, px", + type: "slider", + min: 1, + max: 10, + step: 1, + default: 2, + }, + ], + run: (img, p) => dilateImage(img, num(p, "radius")), + }, + { + id: "make-thinner-png", + title: "Thin PNG", + description: + "Shrinks opaque areas — thins the strokes of text and details.", + category: "alpha", + params: [ + { + id: "radius", + label: "Amount, px", + type: "slider", + min: 1, + max: 10, + step: 1, + default: 1, + }, + ], + run: (img, p) => erodeImage(img, num(p, "radius")), + }, + { + id: "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", + params: [ + { + id: "radius", + label: "Feather radius, px", + type: "slider", + min: 1, + max: 20, + step: 1, + default: 3, + }, + ], + run: (img, p) => featherAlpha(img, num(p, "radius")), + }, + { + id: "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", + params: [ + { + id: "radius", + label: "Search radius, px", + type: "slider", + min: 1, + max: 10, + step: 1, + default: 3, + }, + ], + run: (img, p) => defringe(img, num(p, "radius")), + }, + { + id: "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", + params: [ + { + id: "threshold", + label: "Alpha threshold, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 50, + }, + ], + run: (img, p) => hardenAlpha(img, num(p, "threshold")), + }, + { + id: "despeckle-alpha-png", + title: "Despeckle PNG", + description: + "Opening: removes lone semi-transparent pixels and small specks.", + category: "alpha", + params: [ + { + id: "radius", + label: "Cleanup radius, px", + type: "slider", + min: 1, + max: 3, + step: 1, + default: 1, + }, + ], + run: (img, p) => openingImage(img, num(p, "radius")), + }, + { + id: "close-holes-png", + title: "Close holes PNG", + description: "Closing: fills lone transparent dots inside the object.", + category: "alpha", + params: [ + { + id: "radius", + label: "Closing radius, px", + type: "slider", + min: 1, + max: 3, + step: 1, + default: 1, + }, + ], + run: (img, p) => closingImage(img, num(p, "radius")), + }, + { + id: "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", + params: [ + { + id: "targetColor", + label: "Color to remove", + type: "color", + default: "#00ff00", + }, + { + id: "tolerance", + label: "Similarity threshold, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 10, + }, + ], + run: (img, p) => + removeColorToAlpha(img, str(p, "targetColor"), num(p, "tolerance")), + preview: (img, p) => + colorMask(img, str(p, "targetColor"), num(p, "tolerance")), + }, + { + id: "watermark-image-png", + title: "Watermark Image PNG", + description: + "Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.", + category: "alpha", + domOnly: true, + needsOverlaySource: true, + params: [ + { + id: "scale", + label: "Mark width, % of canvas", + type: "slider", + min: 5, + max: 100, + step: 1, + default: 30, + }, + { + id: "opacity", + label: "Opacity, %", + type: "slider", + min: 5, + max: 100, + step: 5, + default: 60, + }, + { + id: "position", + label: "Position", + type: "select", + default: "bottom-right", + options: [ + { value: "top-left", label: "Top left" }, + { value: "top-center", label: "Top center" }, + { value: "top-right", label: "Top right" }, + { value: "middle-left", label: "Middle left" }, + { value: "center", label: "Center" }, + { value: "middle-right", label: "Middle right" }, + { value: "bottom-left", label: "Bottom left" }, + { value: "bottom-center", label: "Bottom center" }, + { value: "bottom-right", label: "Bottom right" }, + ], + }, + { + id: "margin", + label: "Margin, px", + type: "slider", + min: 0, + max: 200, + step: 1, + default: 24, + }, + ], + run: (img, p) => { + const mark = getOverlay(); + if (!mark) throw new ToolError("errors.noWatermark"); + return drawImageWatermark(img, { + mark, + scalePercent: num(p, "scale"), + opacityPercent: num(p, "opacity"), + position: str(p, "position") as Position9, + margin: num(p, "margin"), + }); + }, + }, + ]; +} diff --git a/web/src/lib/registry/analyze.ts b/web/src/lib/registry/analyze.ts new file mode 100644 index 0000000..32b4e03 --- /dev/null +++ b/web/src/lib/registry/analyze.ts @@ -0,0 +1,289 @@ +import type { ToolEntry } from "../registry"; +import { + extractByColor, + isGrayscaleish, + luma01, + rarityPredicate, + renderPredicateMask, +} from "../core/masks"; +import { hasTransparency, isGrayscale, orientationOf } from "../core/analyze"; +import { looksLikePng, base64ToBytes, stripDataUri } from "../core/textio"; +import { clonePixelImage, type PixelImage } from "../core/types"; +import { encode } from "../core/io"; +import { t } from "../i18n/t"; +import { num, str } from "../registry-helpers"; + +type MaskToolSpec = { + id: string; + title: string; + description: string; + defaultMode: "binary" | "highlight"; + predicate: ( + img: PixelImage, + p: Record, + ) => (r: number, g: number, b: number, a: number) => boolean; + extraParams?: ToolEntry["params"]; +}; + +const MASK_TOOLS: MaskToolSpec[] = [ + { + id: "show-transparent-png", + title: "Show Transparent Areas PNG", + description: + "Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.", + defaultMode: "highlight", + predicate: (_img, _p) => (_r, _g, _b, a) => a < 255, + }, + { + id: "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.", + defaultMode: "binary", + predicate: (_i, p) => (r, g, b) => + isGrayscaleish(r, g, b, num(p, "tolerance")), + extraParams: [ + { + id: "tolerance", + label: "Channel tolerance", + type: "slider", + min: 0, + max: 64, + step: 1, + default: 0, + }, + ], + }, + { + id: "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.", + defaultMode: "binary", + predicate: (_i, p) => (r, g, b) => + !isGrayscaleish(r, g, b, num(p, "tolerance")), + extraParams: [ + { + id: "tolerance", + label: "Channel tolerance", + type: "slider", + min: 0, + max: 64, + step: 1, + default: 8, + }, + ], + }, + { + id: "light-pixel-mask-png", + title: "Light Pixel Mask PNG", + description: "Selects pixels brighter than the luminance threshold.", + defaultMode: "binary", + predicate: (_i, p) => (r, g, b) => + luma01(r, g, b) >= num(p, "threshold") / 100, + extraParams: [ + { + id: "threshold", + label: "Luminance threshold, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 70, + }, + ], + }, + { + id: "dark-pixel-mask-png", + title: "Dark Pixel Mask PNG", + description: "Selects pixels darker than the luminance threshold.", + defaultMode: "binary", + predicate: (_i, p) => (r, g, b) => + luma01(r, g, b) <= num(p, "threshold") / 100, + extraParams: [ + { + id: "threshold", + label: "Luminance threshold, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 30, + }, + ], + }, + { + id: "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.", + defaultMode: "binary", + predicate: (img, p) => rarityPredicate(img, num(p, "rarity")), + extraParams: [ + { + id: "rarity", + label: "Max occurrences", + type: "slider", + min: 1, + max: 50, + step: 1, + default: 1, + }, + ], + }, +]; + +function maskEntries(): ToolEntry[] { + return MASK_TOOLS.map((spec) => ({ + id: spec.id, + title: spec.title, + description: spec.description, + category: "analyze" as const, + params: [ + ...(spec.extraParams ?? []), + { + id: "mode", + label: "Mask mode", + type: "select" as const, + default: spec.defaultMode, + options: [ + { value: "binary", label: "Black & white mask" }, + { value: "highlight", label: "Color highlight" }, + ], + }, + { + id: "color", + label: "Highlight color", + type: "color", + default: "#ff00aa", + }, + { + id: "opacity", + label: "Highlight opacity, %", + type: "slider", + min: 0, + max: 100, + step: 5, + default: 70, + }, + ], + run: (img, p) => + renderPredicateMask(img, spec.predicate(img, p), { + mode: str(p, "mode") === "highlight" ? "highlight" : "binary", + color: str(p, "color"), + opacityPercent: num(p, "opacity"), + }), + })); +} + +export function analyzeEntries(): ToolEntry[] { + return [ + ...maskEntries(), + { + id: "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", + params: [ + { + id: "color", + label: "Color to keep", + type: "color", + default: "#00ff88", + }, + { + id: "tolerance", + label: "Similarity tolerance, %", + type: "slider", + min: 0, + max: 50, + step: 1, + default: 10, + }, + ], + run: (img, p) => + extractByColor(img, str(p, "color"), num(p, "tolerance")), + }, + { + id: "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", + sourceMode: "text", + params: [], + resultType: "text", + textToText: (text) => + looksLikePng(base64ToBytes(stripDataUri(text))) + ? "verifyYes" + : "verifyNo", + }, + { + id: "png-info", + title: "PNG info", + description: + "Shows dimensions, alpha presence and the number of unique colors of the uploaded image.", + category: "analyze", + params: [], + resultType: "info", + run: (img) => clonePixelImage(img), + }, + { + id: "png-is-grayscale", + title: "Check: is PNG grayscale?", + description: "Reports whether the image consists only of shades of gray.", + category: "analyze", + params: [], + resultType: "text", + toText: (img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo"), + }, + { + id: "png-file-size", + title: "PNG File Size", + description: + "Encodes the image as PNG and reports the resulting file size.", + category: "analyze", + domOnly: true, + params: [], + resultType: "text", + toText: async (img) => { + const blob = await encode(img, "image/png"); + const kb = blob.size / 1024; + const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1); + return t("tools.png-file-size.results.line", { + kb: kbText, + }); + }, + }, + { + id: "png-is-transparent", + title: "Check: is PNG transparent?", + description: + "Reports whether the image contains transparent or semi-transparent pixels.", + category: "analyze", + params: [], + resultType: "text", + toText: (img) => + hasTransparency(img) ? "transparentYes" : "transparentNo", + }, + { + id: "png-orientation", + title: "PNG orientation", + description: "Reports whether it is portrait, landscape or square.", + category: "analyze", + params: [], + resultType: "text", + toText: (img) => { + switch (orientationOf(img)) { + case "portrait": + return "orientationPortrait"; + case "landscape": + return "orientationLandscape"; + default: + return "orientationSquare"; + } + }, + }, + ]; +} diff --git a/web/src/lib/registry/color.ts b/web/src/lib/registry/color.ts new file mode 100644 index 0000000..cf8c03f --- /dev/null +++ b/web/src/lib/registry/color.ts @@ -0,0 +1,501 @@ +import type { ToolEntry } from "../registry"; +import { + autoContrast, + brightnessContrast, + changeHue, + extractChannel, + gammaCorrection, + grayscale, + invert, + posterize, + sepia, + setOpacity, + swapChannels, + temperature, + tint, + thresholdBlackWhite, + twoColors, + type ChannelSwapPair, + type RgbChannel, +} from "../core/color"; +import { renderSpace, SPACES, type SpaceId } from "../core/channels"; +import { quantizeImage, ditherImage, mapToNearest } from "../core/quantize"; +import { parseHexList } from "../core/palette"; +import { num, str } from "../registry-helpers"; + +type SpaceEntry = { + id: SpaceId; + suffix: string; + title: string; + description: string; +}; + +const CHANNEL_SPACES: SpaceEntry[] = [ + { + id: "hsl", + suffix: "hsl", + title: "Split PNG into HSL", + description: + "Decomposes the image into Hue, Saturation and Lightness components.", + }, + { + id: "hsv", + suffix: "hsv", + title: "Split PNG into HSV", + description: + "Decomposes the image into Hue, Saturation and Value (brightness) components.", + }, + { + id: "hsi", + suffix: "hsi", + title: "Split PNG into HSI", + description: + "Decomposes the image into Hue, Saturation and Intensity components.", + }, + { + id: "cmyk", + suffix: "cmyk", + title: "Convert PNG to CMYK Colors", + description: + "Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.", + }, + { + id: "ycbcr", + suffix: "ycbcr", + title: "Convert PNG to YCbCr Colors", + description: + "Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.", + }, + { + id: "lab", + suffix: "lab", + title: "Convert PNG to LAB Colors", + description: + "Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.", + }, +]; + +function channelEntries(): ToolEntry[] { + return CHANNEL_SPACES.map((space) => { + const components = SPACES[space.id].components; + return { + id: `png-to-${space.suffix}`, + title: space.title, + description: space.description, + category: "color" as const, + params: [ + { + id: "component", + label: "Component", + type: "select" as const, + default: components[0], + options: components.map((c) => ({ + value: c, + label: c.toUpperCase(), + })), + }, + { + id: "display", + label: "Display mode", + type: "select" as const, + default: "gray", + options: [ + { value: "gray", label: "Grayscale" }, + { value: "color", label: "Space as RGB" }, + ], + }, + ], + run: (img, p) => + renderSpace( + img, + space.id, + str(p, "component"), + str(p, "display") === "color" ? "color" : "gray", + ), + } satisfies ToolEntry; + }); +} + +export function colorEntries(): ToolEntry[] { + return [ + ...channelEntries(), + { + id: "grayscale-png", + title: "Grayscale PNG", + description: + "Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.", + category: "color", + params: [], + run: (img) => grayscale(img), + }, + { + id: "invert-colors-png", + title: "Invert colors PNG", + description: + "Inverts each color channel (255 − value). Alpha is unchanged.", + category: "color", + params: [], + run: (img) => invert(img), + }, + { + id: "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", + params: [ + { + id: "brightness", + label: "Brightness", + type: "slider", + min: -100, + max: 100, + step: 1, + default: 0, + }, + { + id: "contrast", + label: "Contrast", + type: "slider", + min: -100, + max: 100, + step: 1, + default: 0, + }, + ], + run: (img, p) => + brightnessContrast(img, num(p, "brightness"), num(p, "contrast")), + }, + { + id: "change-png-opacity", + title: "Change PNG opacity", + description: + "Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.", + category: "color", + params: [ + { + id: "percent", + label: "Opacity, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 100, + }, + ], + run: (img, p) => setOpacity(img, num(p, "percent")), + }, + { + id: "sepia-png", + title: "Sepia effect", + description: + "Tints the image into the warm brown tones of classic sepia.", + category: "color", + params: [], + run: (img) => sepia(img), + }, + { + id: "change-png-hue", + title: "Change hue PNG", + description: + "Shifts the hue around the circle. Saturation and lightness are preserved.", + category: "color", + params: [ + { + id: "degrees", + label: "Hue shift, °", + type: "slider", + min: -180, + max: 180, + step: 1, + default: 0, + }, + ], + run: (img, p) => changeHue(img, num(p, "degrees")), + }, + { + id: "extract-channel-png", + title: "Extract channel PNG", + description: + "Keeps only the chosen channel — red, green or blue — as shades of gray.", + category: "color", + params: [ + { + id: "channel", + label: "Channel", + type: "select", + default: "red", + options: [ + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + { value: "blue", label: "Blue" }, + ], + }, + ], + run: (img, p) => extractChannel(img, str(p, "channel") as RgbChannel), + }, + { + id: "swap-channels-png", + title: "Swap channels PNG", + description: + "Swaps two color channels — a quick way to get unusual coloring.", + category: "color", + params: [ + { + id: "pair", + label: "Channel pair", + type: "select", + default: "r-g", + options: [ + { value: "r-g", label: "Red ↔ Green" }, + { value: "r-b", label: "Red ↔ Blue" }, + { value: "g-b", label: "Green ↔ Blue" }, + ], + }, + ], + run: (img, p) => swapChannels(img, str(p, "pair") as ChannelSwapPair), + }, + { + id: "black-and-white-png", + title: "Black & white threshold PNG", + description: + "Hard binarization by luminance: every pixel becomes black or white.", + category: "color", + params: [ + { + id: "threshold", + label: "Brightness threshold, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 50, + }, + ], + run: (img, p) => thresholdBlackWhite(img, num(p, "threshold")), + }, + { + id: "posterize-png", + title: "Posterize PNG", + description: + "Reduces the number of levels per channel — a poster effect.", + category: "color", + params: [ + { + id: "levels", + label: "Levels per channel", + type: "slider", + min: 2, + max: 16, + step: 1, + default: 4, + }, + ], + run: (img, p) => posterize(img, num(p, "levels")), + }, + { + id: "two-colors-png", + title: "Two colors PNG", + description: + "Recolors the image into two chosen colors by luminance threshold.", + category: "color", + params: [ + { + id: "lightColor", + label: "Light areas color", + type: "color", + default: "#ffffff", + }, + { + id: "darkColor", + label: "Dark areas color", + type: "color", + default: "#000000", + }, + { + id: "threshold", + label: "Brightness threshold, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 50, + }, + ], + run: (img, p) => + twoColors( + img, + str(p, "lightColor"), + str(p, "darkColor"), + num(p, "threshold"), + ), + }, + { + id: "gamma-png", + title: "Gamma correction PNG", + description: + "Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.", + category: "color", + params: [ + { + id: "value", + label: "Gamma", + type: "slider", + min: 0.1, + max: 3, + step: 0.05, + default: 1, + }, + ], + run: (img, p) => gammaCorrection(img, num(p, "value")), + }, + { + id: "auto-contrast-png", + title: "Auto contrast PNG", + description: + "Stretches each channel's range across the full available brightness range.", + category: "color", + params: [], + run: (img) => autoContrast(img), + }, + { + id: "temperature-png", + title: "Temperature PNG", + description: + "Positive values make the image warmer (more orange), negative ones cooler (more blue).", + category: "color", + params: [ + { + id: "percent", + label: "Temperature", + type: "slider", + min: -100, + max: 100, + step: 1, + default: 0, + }, + ], + run: (img, p) => temperature(img, num(p, "percent")), + }, + { + id: "tint-png", + title: "Tint PNG", + description: + "Multiplies color channels by the chosen tint with the given strength.", + category: "color", + params: [ + { + id: "color", + label: "Tint color", + type: "color", + default: "#ffb060", + }, + { + id: "strength", + label: "Strength, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 30, + }, + ], + run: (img, p) => tint(img, str(p, "color"), num(p, "strength")), + }, + { + id: "quantize-png", + title: "Quantize PNG", + description: + "Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.", + category: "color", + params: [ + { + id: "colors", + label: "Colors (k)", + type: "slider", + min: 2, + max: 64, + step: 1, + default: 16, + }, + ], + run: (img, p) => quantizeImage(img, num(p, "colors")).image, + }, + { + id: "decrease-color-count-png", + title: "Decrease Color Count PNG", + description: + "Same median-cut engine with fixed power-of-two presets — quick way to drop to 2–256 colors.", + category: "color", + params: [ + { + id: "maxColors", + label: "Max colors", + type: "select", + default: "16", + options: [ + { value: "2", label: "2" }, + { value: "4", label: "4" }, + { value: "8", label: "8" }, + { value: "16", label: "16" }, + { value: "32", label: "32" }, + { value: "64", label: "64" }, + { value: "128", label: "128" }, + { value: "256", label: "256" }, + ], + }, + ], + run: (img, p) => quantizeImage(img, num(p, "maxColors")).image, + }, + { + id: "custom-palette-png", + title: "Custom Palette PNG", + description: + "Maps every pixel to the nearest color from your comma-separated hex list.", + category: "color", + params: [ + { + id: "colors", + label: "Palette (comma-separated hex)", + type: "text", + default: "#000000,#ffffff", + }, + ], + run: (img, p) => mapToNearest(img, parseHexList(str(p, "colors"))), + }, + { + id: "dithering-png", + title: "Dithering PNG", + description: + "Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.", + category: "color", + params: [ + { + id: "colors", + label: "Colors (k)", + type: "slider", + min: 2, + max: 16, + step: 1, + default: 4, + }, + { + id: "pattern", + label: "Pattern", + type: "select", + default: "floyd-steinberg", + options: [ + { value: "floyd-steinberg", label: "Floyd–Steinberg" }, + { value: "bayer", label: "Bayer 4×4" }, + ], + }, + ], + run: (img, p) => + ditherImage( + img, + num(p, "colors"), + str(p, "pattern") === "bayer" ? "bayer" : "floyd-steinberg", + ), + }, + ]; +} diff --git a/web/src/lib/registry/convert.ts b/web/src/lib/registry/convert.ts new file mode 100644 index 0000000..ed01041 --- /dev/null +++ b/web/src/lib/registry/convert.ts @@ -0,0 +1,354 @@ +import type { ToolEntry } from "../registry"; +import { clonePixelImage } from "../core/types"; +import { flattenOntoColor } from "../core/alpha"; +import { toBase64, toDataUrl, decodeSvgText, encode } from "../core/io"; +import { pixelsToHex, hexToPixels } from "../core/text"; +import { + imageToByteRows, + bytesToImage, + imageToRgbValues, + rgbValuesToImage, +} from "../core/textio"; +import { + COMPRESSION_LEVELS, + findMaxColorsWithin, + type CompressionLevel, +} from "../core/compress"; +import { quantizeImage } from "../core/quantize"; +import { num, str } from "../registry-helpers"; + +function decodeToPng( + id: string, + title: string, + description: string, +): ToolEntry { + return { + id, + title, + description, + category: "convert", + params: [], + run: (img) => clonePixelImage(img), + }; +} + +export function convertEntries(): ToolEntry[] { + return [ + decodeToPng( + "jpg-to-png", + "Convert JPG to PNG", + "Opens a JPEG and saves it as lossless PNG. Transparency, if present, is preserved.", + ), + decodeToPng( + "webp-to-png", + "Convert WebP to PNG", + "Re-encodes a WebP image into universal PNG.", + ), + decodeToPng( + "gif-to-png", + "Convert GIF to PNG", + "Extracts the first frame of a GIF animation and saves it as PNG.", + ), + decodeToPng( + "bmp-to-png", + "Convert BMP to PNG", + "Re-encodes BMP into compact lossless PNG.", + ), + decodeToPng( + "ico-to-png", + "Convert ICO to PNG", + "Turns an .ico icon into a regular PNG of the chosen size.", + ), + { + id: "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", + params: [], + run: (img) => flattenOntoColor(img, "#000000"), + output: { mime: "image/bmp", ext: "bmp" }, + }, + { + id: "png-to-base64", + title: "PNG to Base64", + description: + "Encodes the image into a base64 string for embedding in code or styles.", + category: "convert", + params: [], + resultType: "text", + toText: (img) => toBase64(img), + }, + { + id: "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", + sourceMode: "text", + params: [], + run: (img) => clonePixelImage(img), + }, + { + id: "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", + params: [], + resultType: "text", + toText: (img) => toDataUrl(img), + }, + { + id: "data-uri-to-png", + title: "Data URI to PNG", + description: "Decodes data:image/…;base64,… back into an image file.", + category: "convert", + sourceMode: "text", + params: [], + run: (img) => clonePixelImage(img), + }, + { + id: "png-to-hex", + title: "PNG to HEX pixels", + description: + "Shows all pixels as rrggbbaa hex values — row by row, space separated.", + category: "convert", + params: [], + resultType: "text", + toText: (img) => pixelsToHex(img), + }, + { + id: "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", + sourceMode: "text", + params: [ + { + id: "width", + label: "Image width", + type: "number", + min: 1, + max: 10000, + step: 1, + default: 1, + }, + ], + runFromText: (text, p) => + hexToPixels(text, Math.trunc(Number(p["width"]))), + run: (img) => clonePixelImage(img), + }, + { + id: "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", + params: [], + resultType: "text", + toText: (img) => imageToByteRows(img), + }, + { + id: "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", + sourceMode: "text", + params: [ + { + id: "width", + label: "Image width", + type: "number", + min: 1, + max: 10000, + step: 1, + default: 32, + }, + ], + runFromText: (text, p) => + bytesToImage( + text, + Math.trunc(num(p as Record, "width")), + ), + run: (img) => clonePixelImage(img), + }, + { + id: "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", + params: [], + resultType: "text", + toText: (img) => imageToRgbValues(img), + }, + { + id: "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", + sourceMode: "text", + params: [ + { + id: "width", + label: "Image width", + type: "number", + min: 1, + max: 10000, + step: 1, + default: 32, + }, + ], + runFromText: (text, p) => + rgbValuesToImage( + text, + Math.trunc(num(p as Record, "width")), + ), + run: (img) => clonePixelImage(img), + }, + { + id: "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", + params: [ + { + id: "background", + label: "Backdrop color", + type: "color", + default: "#ffffff", + }, + { + id: "quality", + label: "JPEG quality", + type: "slider", + min: 1, + max: 100, + step: 1, + default: 90, + }, + ], + output: { + mime: "image/jpeg", + ext: "jpg", + qualityParamId: "quality", + }, + run: (img, p) => flattenOntoColor(img, str(p, "background")), + }, + { + id: "convert-png-to-webp", + title: "Convert PNG to WebP", + description: + "Re-encodes the image into WebP with adjustable quality. Transparency is preserved.", + category: "convert", + params: [ + { + id: "quality", + label: "WebP quality", + type: "slider", + min: 1, + max: 100, + step: 1, + default: 90, + }, + ], + output: { + mime: "image/webp", + ext: "webp", + qualityParamId: "quality", + }, + run: (img) => clonePixelImage(img), + }, + { + id: "compress-png", + title: "Compress PNG", + description: + "Shrinks the PNG by reducing its palette to a preset level. Honest trade-off: fewer colors = smaller file.", + category: "convert", + params: [ + { + id: "level", + label: "Compression level", + type: "select", + default: "balanced", + options: [ + { value: "light", label: "Light (192 colors)" }, + { value: "balanced", label: "Balanced (96 colors)" }, + { value: "strong", label: "Strong (44 colors)" }, + { value: "extreme", label: "Extreme (16 colors)" }, + ], + }, + ], + run: (img, p) => { + const level = str(p, "level") as CompressionLevel; + const k = COMPRESSION_LEVELS[level] ?? COMPRESSION_LEVELS.balanced; + return quantizeImage(img, k).image; + }, + }, + { + id: "reduce-to-size-png", + title: "Reduce PNG to Size", + description: + "Binary-searches the palette size until the encoded PNG fits the target KB. Best effort: if even 2 colors exceed the target, returns the 2-color version.", + category: "convert", + domOnly: true, + params: [ + { + id: "targetKB", + label: "Target size, KB", + type: "slider", + min: 5, + max: 2000, + step: 5, + default: 100, + }, + { + id: "maxColors", + label: "Max colors to try", + type: "slider", + min: 2, + max: 256, + step: 1, + default: 256, + }, + ], + run: async (img, p) => { + const targetBytes = num(p, "targetKB") * 1024; + const maxK = num(p, "maxColors"); + const encodeSize = async (k: number): Promise => { + const blob = await encode(quantizeImage(img, k).image, "image/png"); + return blob.size; + }; + const k = await findMaxColorsWithin(targetBytes, maxK, encodeSize); + return quantizeImage(img, k).image; + }, + }, + { + id: "svg-to-png", + title: "SVG to PNG", + description: + "Decodes SVG markup into a raster image. Paste the SVG code on the left.", + category: "convert", + sourceMode: "text", + params: [ + { + id: "width", + label: "Result width, px", + type: "number", + min: 1, + max: 10000, + step: 1, + default: 512, + }, + ], + runFromText: (text, p) => + decodeSvgText(text, Math.trunc(num(p, "width"))), + run: (img) => clonePixelImage(img), + }, + ]; +} diff --git a/web/src/lib/registry/filters.ts b/web/src/lib/registry/filters.ts new file mode 100644 index 0000000..f498882 --- /dev/null +++ b/web/src/lib/registry/filters.ts @@ -0,0 +1,209 @@ +import type { ToolEntry } from "../registry"; +import { gaussianBlur, sharpen as sharpenImage } from "../core/convolution"; +import { vignette } from "../core/effects"; +import { + pixelate, + shuffleBlocks, + addNoise, + silhouette, +} from "../core/pixel-fx"; +import { jpegRoundtrip } from "../core/io"; +import { num, str } from "../registry-helpers"; + +export function filterEntries(): ToolEntry[] { + return [ + { + id: "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", + params: [ + { + id: "radius", + label: "Radius, px", + type: "slider", + min: 1, + max: 32, + step: 1, + default: 4, + }, + ], + run: (img, p) => gaussianBlur(img, num(p, "radius")), + }, + { + id: "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", + params: [ + { + id: "strength", + label: "Strength, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 50, + }, + ], + run: (img, p) => sharpenImage(img, num(p, "strength")), + }, + { + id: "vignette-png", + title: "Vignette PNG", + description: + "Smoothly darkens the edges of the image, leaving the center untouched.", + category: "filters", + params: [ + { + id: "strength", + label: "Darkening strength, %", + type: "slider", + min: 0, + max: 100, + step: 5, + default: 50, + }, + ], + run: (img, p) => vignette(img, num(p, "strength")), + }, + { + id: "pixelate-png", + title: "Pixelate PNG", + description: + "Averages every blockSize×blockSize area into one color — classic mosaic.", + category: "filters", + params: [ + { + id: "blockSize", + label: "Block size, px", + type: "slider", + min: 2, + max: 64, + step: 1, + default: 8, + }, + ], + run: (img, p) => pixelate(img, num(p, "blockSize")), + }, + { + id: "randomize-pixels-png", + title: "Randomize Pixels PNG", + description: + "Shuffles blocks of the image between positions. Same seed gives the same arrangement.", + category: "filters", + params: [ + { + id: "blockSize", + label: "Block size, px", + type: "slider", + min: 1, + max: 64, + step: 1, + default: 8, + }, + { + id: "seed", + label: "Seed", + type: "number", + min: 0, + max: 999999, + step: 1, + default: 42, + }, + ], + run: (img, p) => shuffleBlocks(img, num(p, "blockSize"), num(p, "seed")), + }, + { + id: "add-noise-png", + title: "Add Noise to PNG", + description: + "Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.", + category: "filters", + params: [ + { + id: "amount", + label: "Amount, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 25, + }, + { + id: "mode", + label: "Noise type", + type: "select", + default: "mono", + options: [ + { value: "mono", label: "Monochrome grain" }, + { value: "color", label: "Color noise" }, + ], + }, + { + id: "seed", + label: "Seed", + type: "number", + min: 0, + max: 999999, + step: 1, + default: 1234, + }, + ], + run: (img, p) => + addNoise( + img, + num(p, "amount"), + str(p, "mode") === "color" ? "color" : "mono", + num(p, "seed"), + ), + }, + { + id: "silhouette-png", + title: "Silhouette PNG", + description: + "Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.", + category: "filters", + params: [ + { + id: "color", + label: "Silhouette color", + type: "color", + default: "#111318", + }, + { + id: "threshold", + label: "Visibility threshold, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 10, + }, + ], + run: (img, p) => + silhouette(img, str(p, "color"), num(p, "threshold") * 2.55), + }, + { + id: "jpeg-artifacts-png", + title: "JPEG artifacts", + description: + "Simulates low-quality JPEG re-compression — visible blocks and smeared colors.", + category: "filters", + params: [ + { + id: "quality", + label: "JPEG quality", + type: "slider", + min: 1, + max: 50, + step: 1, + default: 10, + }, + ], + run: (img, p) => jpegRoundtrip(img, num(p, "quality")), + }, + ]; +} diff --git a/web/src/lib/registry/generate.ts b/web/src/lib/registry/generate.ts new file mode 100644 index 0000000..2190b28 --- /dev/null +++ b/web/src/lib/registry/generate.ts @@ -0,0 +1,942 @@ +import type { ToolEntry } from "../registry"; +import { ToolError } from "../core/errors"; +import { solidImage, noiseImage, gradientImage } from "../core/generate"; +import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools"; +import { renderTextToImage, renderEmoji, type TextFont } from "../core/domText"; +import { changeCanvasSize } from "../core/geometry"; +import { + renderWheel, + renderSwatches, + renderBlend, + complementarySet, + triadicSet, + tetradicSet, + analogousSet, + monochromaticSet, + shadeSet, + mixColors, + parseHexList, + stepColors, + sortPalette, + type SortKey, +} from "../core/palette"; +import { num, str, bool } from "../registry-helpers"; + +function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { + const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) { + throw new ToolError("errors.badHex", { value: hex }); + } + const d = match[1]; + return [ + parseInt(d.slice(0, 2), 16), + parseInt(d.slice(2, 4), 16), + parseInt(d.slice(4, 6), 16), + alpha, + ]; +} + +function paletteParams(baseDefault: string) { + return [ + { + id: "baseColor", + label: "Base color", + type: "color" as const, + default: baseDefault, + }, + { + id: "width", + label: "Width", + type: "slider" as const, + min: 128, + max: 1024, + step: 16, + default: 512, + }, + { + id: "layout", + label: "Layout", + type: "select" as const, + default: "grid", + options: [ + { value: "grid", label: "Grid" }, + { value: "strip", label: "Strip" }, + ], + }, + ]; +} + +export function generateEntries(): ToolEntry[] { + return [ + { + id: "create-empty-png", + title: "Create empty PNG", + description: + "Generates a canvas of the chosen size — transparent or filled with color.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 800, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 600, + }, + { + id: "transparent", + label: "Transparent", + type: "checkbox", + default: true, + }, + { + id: "color", + label: "Color", + type: "color", + default: "#ffffff", + }, + ], + generate: (p) => + solidImage( + Math.trunc(num(p, "width")), + Math.trunc(num(p, "height")), + p["transparent"] === true ? [0, 0, 0, 0] : hexToRgba(str(p, "color")), + ), + }, + { + id: "single-color-png", + title: "Create solid color PNG", + description: "Generates a rectangle of the given size and color.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 256, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 256, + }, + { + id: "color", + label: "Color", + type: "color", + default: "#ff0000", + }, + ], + generate: (p) => + solidImage( + Math.trunc(num(p, "width")), + Math.trunc(num(p, "height")), + hexToRgba(str(p, "color")), + ), + }, + { + id: "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", + sourceMode: "none", + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 512, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 512, + }, + { + id: "seed", + label: "Seed", + type: "number", + min: 0, + max: 999999999, + step: 1, + default: 1, + }, + ], + generate: (p) => + noiseImage( + Math.trunc(num(p, "width")), + Math.trunc(num(p, "height")), + num(p, "seed"), + ), + }, + { + id: "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", + sourceMode: "none", + domOnly: true, + params: [ + { + id: "text", + label: "Text", + type: "text", + default: "Hello!", + placeholder: "Your text", + }, + { + id: "fontSize", + label: "Font size, px", + type: "slider", + min: 8, + max: 300, + step: 1, + default: 96, + }, + { + id: "font", + label: "Font", + type: "select", + default: "sans", + options: [ + { value: "sans", label: "Sans-serif" }, + { value: "serif", label: "Serif" }, + { value: "mono", label: "Monospace" }, + ], + }, + { + id: "bold", + label: "Bold", + type: "checkbox", + default: true, + }, + { + id: "color", + label: "Text color", + type: "color", + default: "#111318", + }, + { + id: "transparentBg", + label: "Transparent background", + type: "checkbox", + default: false, + }, + { + id: "backgroundColor", + label: "Background color", + type: "color", + default: "#ffffff", + }, + { + id: "padding", + label: "Padding, px", + type: "slider", + min: 0, + max: 200, + step: 2, + default: 24, + }, + ], + generate: (p) => + renderTextToImage({ + text: str(p, "text"), + fontSize: num(p, "fontSize"), + font: str(p, "font") as TextFont, + bold: bool(p, "bold"), + color: str(p, "color"), + backgroundColor: str(p, "backgroundColor"), + transparentBg: bool(p, "transparentBg"), + padding: num(p, "padding"), + }), + }, + { + id: "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", + sourceMode: "none", + domOnly: true, + params: [ + { + id: "emoji", + label: "Emoji / symbol", + type: "text", + default: "\u{1F600}", + }, + { + id: "size", + label: "Size", + type: "slider", + min: 32, + max: 1024, + step: 16, + default: 256, + }, + ], + generate: (p) => renderEmoji(str(p, "emoji"), Math.trunc(num(p, "size"))), + }, + { + id: "placeholder-png", + title: "Create Placeholder PNG", + description: + "Generates a placeholder rectangle with its dimensions printed in the center.", + category: "generate", + sourceMode: "none", + domOnly: true, + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 800, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 400, + }, + { + id: "backgroundColor", + label: "Background", + type: "color", + default: "#dfe2e8", + }, + { + id: "color", + label: "Text color", + type: "color", + default: "#5c6470", + }, + { + id: "showText", + label: "Print dimensions", + type: "checkbox", + default: true, + }, + ], + generate: (p) => { + const w = Math.trunc(num(p, "width")); + const h = Math.trunc(num(p, "height")); + const label = renderTextToImage({ + text: `${w} × ${h}`, + fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)), + font: "sans", + bold: true, + color: str(p, "color"), + backgroundColor: str(p, "backgroundColor"), + transparentBg: false, + padding: 0, + }); + return changeCanvasSize(label, w, h, "center"); + }, + }, + { + id: "linear-gradient-png", + title: "Create gradient PNG", + description: + "Generates a smooth transition between two colors, horizontally or vertically.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 800, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 600, + }, + { + id: "fromColor", + label: "Start color", + type: "color", + default: "#000000", + }, + { + id: "toColor", + label: "End color", + type: "color", + default: "#ffffff", + }, + { + id: "direction", + label: "Direction", + type: "select", + default: "horizontal", + options: [ + { value: "horizontal", label: "Horizontal" }, + { value: "vertical", label: "Vertical" }, + ], + }, + ], + generate: (p) => + gradientImage( + Math.trunc(num(p, "width")), + Math.trunc(num(p, "height")), + hexToRgba(str(p, "fromColor")), + hexToRgba(str(p, "toColor")), + str(p, "direction") === "vertical" ? "vertical" : "horizontal", + ), + }, + { + id: "color-spectrum-png", + title: "Color Spectrum PNG", + description: + "Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 1024, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 128, + }, + { + id: "direction", + label: "Direction", + type: "select", + default: "horizontal", + options: [ + { value: "horizontal", label: "Horizontal" }, + { value: "vertical", label: "Vertical" }, + ], + }, + { + id: "saturation", + label: "Saturation, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 100, + }, + { + id: "lightness", + label: "Lightness, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 50, + }, + ], + generate: (p) => + colorSpectrum( + Math.trunc(num(p, "width")), + Math.trunc(num(p, "height")), + str(p, "direction") === "vertical" ? "vertical" : "horizontal", + num(p, "saturation"), + num(p, "lightness"), + ), + }, + { + id: "random-colors-png", + title: "Random Color Blocks PNG", + description: + "Fills the canvas with random vivid color blocks. Deterministic by seed.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 512, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 512, + }, + { + id: "blockSize", + label: "Block size, px", + type: "slider", + min: 4, + max: 256, + step: 2, + default: 64, + }, + { + id: "seed", + label: "Seed", + type: "number", + min: 0, + max: 999999999, + step: 1, + default: 7, + }, + ], + generate: (p) => + randomColorBlocks( + Math.trunc(num(p, "width")), + Math.trunc(num(p, "height")), + num(p, "blockSize"), + num(p, "seed"), + ), + }, + { + id: "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", + sourceMode: "none", + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 512, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 5000, + step: 1, + default: 512, + }, + { + id: "cols", + label: "Columns", + type: "slider", + min: 1, + max: 64, + step: 1, + default: 8, + }, + { + id: "rows", + label: "Rows", + type: "slider", + min: 1, + max: 64, + step: 1, + default: 8, + }, + { + id: "lineWidth", + label: "Line width, px", + type: "slider", + min: 1, + max: 40, + step: 1, + default: 2, + }, + { + id: "color", + label: "Line color", + type: "color", + default: "#111318", + }, + { + id: "transparentBg", + label: "Transparent background", + type: "checkbox", + default: true, + }, + ], + generate: (p) => + drawGrid( + Math.trunc(num(p, "width")), + Math.trunc(num(p, "height")), + num(p, "cols"), + num(p, "rows"), + num(p, "lineWidth"), + str(p, "color"), + bool(p, "transparentBg"), + ), + }, + { + id: "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", + sourceMode: "none", + params: [ + { + id: "width", + label: "Size", + type: "slider", + min: 128, + max: 1024, + step: 16, + default: 512, + }, + { + id: "lightness", + label: "Lightness, %", + type: "slider", + min: 0, + max: 100, + step: 1, + default: 50, + }, + ], + generate: (p) => + renderWheel(Math.trunc(num(p, "width")), num(p, "lightness")), + }, + { + id: "complementary-png", + title: "Complementary Palette PNG", + description: + "Two opposite colors on the color wheel — the base and its complement.", + category: "generate", + sourceMode: "none", + params: paletteParams("#2563eb"), + generate: (p) => + renderSwatches( + complementarySet(str(p, "baseColor")), + num(p, "width"), + str(p, "layout") as "strip" | "grid", + ), + }, + { + id: "triadic-png", + title: "Triadic Palette PNG", + description: "Three colors evenly spaced 120° apart on the color wheel.", + category: "generate", + sourceMode: "none", + params: paletteParams("#ff0000"), + generate: (p) => + renderSwatches( + triadicSet(str(p, "baseColor")), + num(p, "width"), + str(p, "layout") as "strip" | "grid", + ), + }, + { + id: "tetradic-png", + title: "Tetradic Palette PNG", + description: + "Four colors in two complementary pairs, 90° apart on the wheel.", + category: "generate", + sourceMode: "none", + params: paletteParams("#8000ff"), + generate: (p) => + renderSwatches( + tetradicSet(str(p, "baseColor")), + num(p, "width"), + str(p, "layout") as "strip" | "grid", + ), + }, + { + id: "analogous-png", + title: "Analogous Palette PNG", + description: + "Neighboring hues around the base color — calm, related color scheme.", + category: "generate", + sourceMode: "none", + params: [ + ...paletteParams("#22c55e"), + { + id: "spread", + label: "Hue spread, °", + type: "slider", + min: 10, + max: 90, + step: 5, + default: 30, + }, + { + id: "count", + label: "Colors", + type: "slider", + min: 3, + max: 9, + step: 1, + default: 5, + }, + ], + generate: (p) => + renderSwatches( + analogousSet(str(p, "baseColor"), num(p, "spread"), num(p, "count")), + num(p, "width"), + str(p, "layout") as "strip" | "grid", + ), + }, + { + id: "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", + sourceMode: "none", + params: [ + ...paletteParams("#0ea5e9"), + { + id: "count", + label: "Colors", + type: "slider", + min: 2, + max: 9, + step: 1, + default: 5, + }, + { + id: "range", + label: "Lightness range, %", + type: "slider", + min: 10, + max: 90, + step: 5, + default: 40, + }, + ], + generate: (p) => + renderSwatches( + monochromaticSet( + str(p, "baseColor"), + num(p, "count"), + num(p, "range"), + ), + num(p, "width"), + str(p, "layout") as "strip" | "grid", + ), + }, + { + id: "shades-png", + title: "Shade Ramp PNG", + description: "A ramp of the base color getting darker step by step.", + category: "generate", + sourceMode: "none", + params: [ + ...paletteParams("#f59e0b"), + { + id: "count", + label: "Colors", + type: "slider", + min: 2, + max: 9, + step: 1, + default: 5, + }, + { + id: "depth", + label: "Darkening depth, %", + type: "slider", + min: 10, + max: 90, + step: 5, + default: 50, + }, + ], + generate: (p) => + renderSwatches( + shadeSet(str(p, "baseColor"), num(p, "count"), num(p, "depth")), + num(p, "width"), + str(p, "layout") as "strip" | "grid", + ), + }, + { + id: "mix-colors-png", + title: "Mix Colors PNG", + description: + "Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "colors", + label: "Colors (comma-separated hex)", + type: "text", + default: "#ff0000,#00ff00,#0000ff", + }, + { + id: "width", + label: "Width", + type: "slider", + min: 128, + max: 1024, + step: 16, + default: 512, + }, + ], + generate: (p) => + renderSwatches( + [mixColors(parseHexList(str(p, "colors")))], + num(p, "width"), + "strip", + ), + }, + { + id: "blend-two-png", + title: "Blend Two Colors PNG", + description: "A continuous horizontal gradient between two colors.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "colorA", + label: "Color A", + type: "color", + default: "#000000", + }, + { + id: "colorB", + label: "Color B", + type: "color", + default: "#ffffff", + }, + { + id: "width", + label: "Width", + type: "slider", + min: 128, + max: 1024, + step: 16, + default: 512, + }, + ], + generate: (p) => + renderBlend(str(p, "colorA"), str(p, "colorB"), num(p, "width")), + }, + { + id: "step-colors-png", + title: "Color Steps PNG", + description: "A discrete set of evenly spaced steps between two colors.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "colorA", + label: "Color A", + type: "color", + default: "#000000", + }, + { + id: "colorB", + label: "Color B", + type: "color", + default: "#ffffff", + }, + { + id: "steps", + label: "Steps", + type: "slider", + min: 2, + max: 12, + step: 1, + default: 6, + }, + ...paletteParams("#808080").filter((q) => q.id !== "baseColor"), + ], + generate: (p) => + renderSwatches( + stepColors(str(p, "colorA"), str(p, "colorB"), num(p, "steps")), + num(p, "width"), + str(p, "layout") as "strip" | "grid", + ), + }, + { + id: "sort-colors-png", + title: "Sort Colors PNG", + description: + "Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.", + category: "generate", + sourceMode: "none", + params: [ + { + id: "colors", + label: "Colors (comma-separated hex)", + type: "text", + default: "#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff", + }, + { + id: "order", + label: "Sort by", + type: "select", + default: "hue", + options: [ + { value: "hue", label: "Hue" }, + { value: "luma", label: "Brightness" }, + { value: "sat", label: "Saturation" }, + ], + }, + ...paletteParams("#ffffff").filter((q) => q.id !== "baseColor"), + ], + generate: (p) => + renderSwatches( + sortPalette( + parseHexList(str(p, "colors")), + str(p, "order") as SortKey, + ), + num(p, "width"), + str(p, "layout") as "strip" | "grid", + ), + }, + ]; +} diff --git a/web/src/lib/registry/geometry.ts b/web/src/lib/registry/geometry.ts new file mode 100644 index 0000000..98e2fa2 --- /dev/null +++ b/web/src/lib/registry/geometry.ts @@ -0,0 +1,657 @@ +import type { ToolEntry } from "../registry"; +import { ToolError } from "../core/errors"; +import { field, toolSchema } from "../registry-schema"; +import { + crop, + expandCanvas, + flip, + resize, + rotate90, + tile, + centerByAlpha, + changeCanvasSize, + cropToRatio, + forceOrientation, + padToRatio, + symmetricCopy, + trimToContent, + type Anchor9, +} from "../core/geometry"; +import { + rotateFreeImage, + skewImage, + transformImage, + zoomImage, +} from "../core/affine"; +import { num, str } from "../registry-helpers"; + +interface AddBorderParams { + thickness: number; + color: string; +} + +const addBorderSchema = toolSchema({ + thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }), + color: field.color({ default: "#000000" }), +}); + +export function geometryEntries(): ToolEntry[] { + return [ + { + id: "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", + params: [ + { + id: "width", + label: "Width (0 = auto)", + type: "number", + min: 0, + max: 20000, + step: 1, + default: 0, + }, + { + id: "height", + label: "Height (0 = auto)", + type: "number", + min: 0, + max: 20000, + step: 1, + default: 0, + }, + { + id: "keepAspect", + label: "Keep aspect ratio", + type: "checkbox", + default: true, + }, + ], + run: (img, p) => { + const keepAspect = p["keepAspect"] === true; + let w = Math.trunc(num(p, "width")); + let h = Math.trunc(num(p, "height")); + if (keepAspect) { + if (w > 0 && h > 0) { + const scale = Math.min(w / img.width, h / img.height); + w = Math.max(1, Math.round(img.width * scale)); + h = Math.max(1, Math.round(img.height * scale)); + } else if (w > 0) { + h = Math.max(1, Math.round((img.height / img.width) * w)); + } else if (h > 0) { + w = Math.max(1, Math.round((img.width / img.height) * h)); + } + } + if (w <= 0 || h <= 0) { + throw new ToolError("errors.resizeSize"); + } + return resize(img, w, h); + }, + }, + { + id: "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", + params: [ + { + id: "x", + label: "X (from left)", + type: "number", + min: -100000, + max: 100000, + step: 1, + default: 0, + }, + { + id: "y", + label: "Y (from top)", + type: "number", + min: -100000, + max: 100000, + step: 1, + default: 0, + }, + { + id: "width", + label: "Area width", + type: "number", + min: -100000, + max: 100000, + step: 1, + default: 0, + }, + { + id: "height", + label: "Area height", + type: "number", + min: -100000, + max: 100000, + step: 1, + default: 0, + }, + ], + run: (img, p) => { + const w = Math.trunc(num(p, "width")); + const h = Math.trunc(num(p, "height")); + if (w <= 0 || h <= 0) { + throw new ToolError("errors.cropSize"); + } + return crop( + img, + Math.trunc(num(p, "x")), + Math.trunc(num(p, "y")), + w, + h, + ); + }, + }, + { + id: "rotate-png", + title: "Rotate PNG", + description: + "Rotates by 90°, 180° or 270° clockwise without quality loss.", + category: "geometry", + params: [ + { + id: "angle", + label: "Rotation angle", + type: "select", + default: "90", + options: [ + { value: "90", label: "90° clockwise" }, + { value: "180", label: "180°" }, + { value: "270", label: "270° clockwise" }, + ], + }, + ], + run: (img, p) => rotate90(img, Number(str(p, "angle")) / 90), + }, + { + id: "flip-png", + title: "Flip PNG", + description: "Mirrors horizontally or vertically without quality loss.", + category: "geometry", + params: [ + { + id: "axis", + label: "Flip axis", + type: "select", + default: "horizontal", + options: [ + { + value: "horizontal", + label: "Horizontal (left to right)", + }, + { + value: "vertical", + label: "Vertical (top to bottom)", + }, + ], + }, + ], + run: (img, p) => + flip(img, str(p, "axis") === "vertical" ? "vertical" : "horizontal"), + }, + { + id: "add-padding-png", + title: "Add padding to PNG", + description: + "Expands the canvas on all sides by the chosen number of pixels.", + category: "geometry", + params: [ + { + id: "padding", + label: "Padding, px", + type: "number", + min: 1, + max: 2000, + step: 1, + default: 10, + }, + { + id: "transparent", + label: "Transparent padding", + type: "checkbox", + default: true, + }, + { + id: "color", + label: "Padding color", + type: "color", + default: "#ffffff", + }, + ], + run: (img, p) => + expandCanvas( + img, + num(p, "padding"), + num(p, "padding"), + num(p, "padding"), + num(p, "padding"), + p["transparent"] === true ? undefined : str(p, "color"), + ), + }, + { + id: "add-border-png", + title: "Add border to PNG", + description: + "Draws a colored frame of the chosen thickness around the image.", + category: "geometry", + schema: addBorderSchema, + params: [ + { + id: "thickness", + label: "Border thickness, px", + type: "number", + min: 1, + max: 500, + step: 1, + default: 5, + }, + { + id: "color", + label: "Border color", + type: "color", + default: "#000000", + }, + ], + run: (img, p) => + expandCanvas( + img, + num(p, "thickness"), + num(p, "thickness"), + num(p, "thickness"), + num(p, "thickness"), + str(p, "color"), + ), + }, + { + id: "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", + params: [ + { + id: "width", + label: "Canvas width", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 800, + }, + { + id: "height", + label: "Canvas height", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 600, + }, + { + id: "transparent", + label: "Transparent background", + type: "checkbox", + default: false, + }, + { + id: "color", + label: "Background color", + type: "color", + default: "#ffffff", + }, + ], + run: (img, p) => { + const width = Math.trunc(num(p, "width")); + const height = Math.trunc(num(p, "height")); + if (width <= 0 || height <= 0) { + throw new ToolError("errors.sizePositive"); + } + const left = Math.max(0, Math.floor((width - img.width) / 2)); + const top = Math.max(0, Math.floor((height - img.height) / 2)); + return expandCanvas( + img, + left, + top, + Math.max(0, width - img.width - left), + Math.max(0, height - img.height - top), + p["transparent"] === true ? undefined : str(p, "color"), + ); + }, + }, + { + id: "tile-png", + title: "Tile PNG", + description: + "Repeats the image in a grid of the chosen columns and rows.", + category: "geometry", + params: [ + { + id: "columns", + label: "Columns", + type: "number", + min: 1, + max: 50, + step: 1, + default: 2, + }, + { + id: "rows", + label: "Rows", + type: "number", + min: 1, + max: 50, + step: 1, + default: 2, + }, + ], + run: (img, p) => tile(img, num(p, "columns"), num(p, "rows")), + }, + { + id: "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", + params: [], + run: (img) => centerByAlpha(img), + }, + { + id: "skew-png", + title: "Skew PNG", + description: + "Shifts content horizontally and vertically — a perspective effect.", + category: "geometry", + params: [ + { + id: "degX", + label: "Skew X, °", + type: "slider", + min: -80, + max: 80, + step: 1, + default: 0, + }, + { + id: "degY", + label: "Skew Y, °", + type: "slider", + min: -80, + max: 80, + step: 1, + default: 0, + }, + ], + run: (img, p) => skewImage(img, num(p, "degX"), num(p, "degY")), + }, + { + id: "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", + params: [ + { + id: "angle", + label: "Angle, °", + type: "slider", + min: -180, + max: 180, + step: 1, + default: 15, + }, + ], + run: (img, p) => rotateFreeImage(img, num(p, "angle")), + }, + { + id: "zoom-png", + title: "Zoom PNG", + description: + "Magnifies content toward the center. The canvas keeps its size — edges are cropped.", + category: "geometry", + params: [ + { + id: "scale", + label: "Scale, %", + type: "slider", + min: 100, + max: 500, + step: 10, + default: 200, + }, + ], + run: (img, p) => zoomImage(img, num(p, "scale")), + }, + { + id: "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", + params: [ + { + id: "threshold", + label: "Alpha threshold", + type: "slider", + min: 0, + max: 254, + step: 1, + default: 0, + }, + ], + run: (img, p) => trimToContent(img, num(p, "threshold")), + }, + { + id: "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", + params: [ + { + id: "width", + label: "Width", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 800, + }, + { + id: "height", + label: "Height", + type: "number", + min: 1, + max: 20000, + step: 1, + default: 600, + }, + { + id: "anchor", + label: "Anchor", + type: "select", + default: "center", + options: [ + { value: "top-left", label: "Top left" }, + { value: "top-center", label: "Top center" }, + { value: "top-right", label: "Top right" }, + { value: "middle-left", label: "Middle left" }, + { value: "center", label: "Center" }, + { value: "middle-right", label: "Middle right" }, + { value: "bottom-left", label: "Bottom left" }, + { value: "bottom-center", label: "Bottom center" }, + { value: "bottom-right", label: "Bottom right" }, + ], + }, + ], + run: (img, p) => + changeCanvasSize( + img, + Math.trunc(num(p, "width")), + Math.trunc(num(p, "height")), + str(p, "anchor") as Anchor9, + ), + }, + { + id: "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", + params: [ + { + id: "ratio", + label: "Target ratio", + type: "select", + default: "1:1", + options: [ + { value: "1:1", label: "1:1" }, + { value: "4:3", label: "4:3" }, + { value: "3:4", label: "3:4" }, + { value: "3:2", label: "3:2" }, + { value: "2:3", label: "2:3" }, + { value: "16:9", label: "16:9" }, + { value: "9:16", label: "9:16" }, + ], + }, + { + id: "mode", + label: "Mode", + type: "select", + default: "crop", + options: [ + { value: "crop", label: "Crop to fill" }, + { value: "pad", label: "Pad to fit" }, + ], + }, + ], + run: (img, p) => { + const [rw, rh] = str(p, "ratio").split(":").map(Number); + const ratio = rw / rh; + return str(p, "mode") === "pad" + ? padToRatio(img, ratio) + : cropToRatio(img, ratio); + }, + }, + { + id: "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", + params: [ + { + id: "target", + label: "Target orientation", + type: "select", + default: "portrait", + options: [ + { value: "portrait", label: "Portrait" }, + { value: "landscape", label: "Landscape" }, + ], + }, + ], + run: (img, p) => + forceOrientation( + img, + str(p, "target") === "landscape" ? "landscape" : "portrait", + ), + }, + { + id: "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", + params: [ + { + id: "axis", + label: "Mirror line", + type: "select", + default: "vertical", + options: [ + { + value: "vertical", + label: "Vertical (double width)", + }, + { + value: "horizontal", + label: "Horizontal (double height)", + }, + ], + }, + { + id: "keepSide", + label: "Keep side", + type: "select", + default: "left", + options: [ + { value: "left", label: "Left" }, + { value: "right", label: "Right" }, + { value: "top", label: "Top" }, + { value: "bottom", label: "Bottom" }, + ], + }, + ], + run: (img, p) => + symmetricCopy( + img, + str(p, "axis") === "horizontal" ? "horizontal" : "vertical", + str(p, "keepSide") as "left" | "right" | "top" | "bottom", + ), + }, + { + id: "shift-png", + title: "Shift PNG", + description: "Moves content by the given X and Y offset.", + category: "geometry", + params: [ + { + id: "offsetX", + label: "Offset X, px", + type: "number", + min: -5000, + max: 5000, + step: 1, + default: 0, + }, + { + id: "offsetY", + label: "Offset Y, px", + type: "number", + min: -5000, + max: 5000, + step: 1, + default: 0, + }, + { + id: "color", + label: "Background color", + type: "color", + default: "#ffffff", + }, + ], + run: (img, p) => + transformImage( + img, + [ + 1, + 0, + 0, + 1, + -Math.trunc(num(p, "offsetX")), + -Math.trunc(num(p, "offsetY")), + ], + img.width, + img.height, + str(p, "color"), + ), + }, + ]; +} diff --git a/web/src/lib/registry/text.ts b/web/src/lib/registry/text.ts new file mode 100644 index 0000000..19ae894 --- /dev/null +++ b/web/src/lib/registry/text.ts @@ -0,0 +1,315 @@ +import type { ToolEntry } from "../registry"; +import { drawTextBlock, drawTextTile, type TextFont } from "../core/domText"; +import { formatStamp } from "../core/datefmt"; +import type { Position9 } from "../core/textdraw"; +import { num, str, bool } from "../registry-helpers"; + +export function textEntries(): ToolEntry[] { + return [ + { + id: "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, + params: [ + { + id: "text", + label: "Text", + type: "text", + default: "Hello!", + placeholder: "Your text", + }, + { + id: "fontSize", + label: "Font size, px", + type: "slider", + min: 8, + max: 200, + step: 1, + default: 48, + }, + { + id: "color", + label: "Text color", + type: "color", + default: "#ffffff", + }, + { + id: "font", + label: "Font", + type: "select", + default: "sans", + options: [ + { value: "sans", label: "Sans-serif" }, + { value: "serif", label: "Serif" }, + { value: "mono", label: "Monospace" }, + ], + }, + { id: "bold", label: "Bold", type: "checkbox", default: true }, + { + id: "position", + label: "Position", + type: "select", + default: "bottom-right", + options: [ + { value: "top-left", label: "Top left" }, + { value: "top-center", label: "Top center" }, + { value: "top-right", label: "Top right" }, + { value: "middle-left", label: "Middle left" }, + { value: "center", label: "Center" }, + { value: "middle-right", label: "Middle right" }, + { value: "bottom-left", label: "Bottom left" }, + { value: "bottom-center", label: "Bottom center" }, + { value: "bottom-right", label: "Bottom right" }, + ], + }, + { + id: "margin", + label: "Margin, px", + type: "slider", + min: 0, + max: 200, + step: 1, + default: 24, + }, + { + id: "plate", + label: "Backing plate", + type: "checkbox", + default: false, + }, + { + id: "plateColor", + label: "Plate color", + type: "color", + default: "#000000", + }, + { + id: "plateOpacity", + label: "Plate opacity, %", + type: "slider", + min: 0, + max: 100, + step: 5, + default: 60, + }, + ], + run: (img, p) => + drawTextBlock(img, { + text: str(p, "text"), + fontSize: num(p, "fontSize"), + font: str(p, "font") as TextFont, + bold: bool(p, "bold"), + color: str(p, "color"), + opacityPercent: 100, + position: str(p, "position") as Position9, + margin: num(p, "margin"), + plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined, + plateOpacityPercent: num(p, "plateOpacity"), + }), + }, + { + id: "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, + params: [ + { + id: "format", + label: "Format", + type: "text", + default: "YYYY-MM-DD", + placeholder: "YYYY-MM-DD hh:mm", + }, + { + id: "fontSize", + label: "Font size, px", + type: "slider", + min: 8, + max: 200, + step: 1, + default: 32, + }, + { + id: "color", + label: "Text color", + type: "color", + default: "#ffffff", + }, + { + id: "font", + label: "Font", + type: "select", + default: "mono", + options: [ + { value: "sans", label: "Sans-serif" }, + { value: "serif", label: "Serif" }, + { value: "mono", label: "Monospace" }, + ], + }, + { id: "bold", label: "Bold", type: "checkbox", default: false }, + { + id: "position", + label: "Position", + type: "select", + default: "bottom-right", + options: [ + { value: "top-left", label: "Top left" }, + { value: "top-center", label: "Top center" }, + { value: "top-right", label: "Top right" }, + { value: "middle-left", label: "Middle left" }, + { value: "center", label: "Center" }, + { value: "middle-right", label: "Middle right" }, + { value: "bottom-left", label: "Bottom left" }, + { value: "bottom-center", label: "Bottom center" }, + { value: "bottom-right", label: "Bottom right" }, + ], + }, + { + id: "margin", + label: "Margin, px", + type: "slider", + min: 0, + max: 200, + step: 1, + default: 20, + }, + { + id: "plate", + label: "Backing plate", + type: "checkbox", + default: true, + }, + { + id: "plateColor", + label: "Plate color", + type: "color", + default: "#000000", + }, + { + id: "plateOpacity", + label: "Plate opacity, %", + type: "slider", + min: 0, + max: 100, + step: 5, + default: 55, + }, + ], + run: (img, p) => + drawTextBlock(img, { + text: formatStamp(new Date(), str(p, "format")), + fontSize: num(p, "fontSize"), + font: str(p, "font") as TextFont, + bold: bool(p, "bold"), + color: str(p, "color"), + opacityPercent: 100, + position: str(p, "position") as Position9, + margin: num(p, "margin"), + plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined, + plateOpacityPercent: num(p, "plateOpacity"), + }), + }, + { + id: "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, + params: [ + { + id: "text", + label: "Text", + type: "text", + default: "DRAFT", + placeholder: "Watermark text", + }, + { + id: "fontSize", + label: "Font size, px", + type: "slider", + min: 12, + max: 160, + step: 1, + default: 56, + }, + { + id: "color", + label: "Text color", + type: "color", + default: "#ffffff", + }, + { + id: "opacity", + label: "Opacity, %", + type: "slider", + min: 5, + max: 100, + step: 5, + default: 30, + }, + { + id: "angle", + label: "Angle, °", + type: "slider", + min: -90, + max: 90, + step: 1, + default: -30, + }, + { + id: "stepX", + label: "Step X, px", + type: "slider", + min: 40, + max: 600, + step: 10, + default: 220, + }, + { + id: "stepY", + label: "Step Y, px", + type: "slider", + min: 40, + max: 600, + step: 10, + default: 180, + }, + { + id: "font", + label: "Font", + type: "select", + default: "sans", + options: [ + { value: "sans", label: "Sans-serif" }, + { value: "serif", label: "Serif" }, + { value: "mono", label: "Monospace" }, + ], + }, + { + id: "bold", + label: "Bold", + type: "checkbox", + default: true, + }, + ], + run: (img, p) => + drawTextTile(img, { + text: str(p, "text"), + fontSize: num(p, "fontSize"), + font: str(p, "font") as TextFont, + bold: bool(p, "bold"), + color: str(p, "color"), + opacityPercent: num(p, "opacity"), + stepX: num(p, "stepX"), + stepY: num(p, "stepY"), + angleDeg: num(p, "angle"), + }), + }, + ]; +}