import type { CategoryId } from './categories'; 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, type TextFont } from './core/domText'; import { getOverlay } from './tools/overlay-store.svelte'; 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 { 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'; export type ParamDef = | { id: string; label: string; type: 'number'; min?: number; max?: number; step?: number; default: number; } | { id: string; label: string; type: 'slider'; min: number; max: number; step?: number; default: number; } | { id: string; label: string; type: 'select'; options: { value: string; label: string }[]; default: string; } | { id: string; label: string; type: 'checkbox'; default: boolean } | { id: string; label: string; type: 'color'; default: string } | { id: string; label: string; type: 'text'; default: string; placeholder?: string }; export type OutputFormat = { mime: OutputMime; ext: string; qualityParamId?: string; }; export type SourceMode = 'file' | 'none' | 'text'; export type ToolEntry = { id: string; title: string; description: string; category: CategoryId; sourceMode?: SourceMode; params: ParamDef[]; run?: (img: PixelImage, params: Record) => Promise | PixelImage; generate?: (params: Record) => Promise | PixelImage; toText?: (img: PixelImage, params: Record) => Promise | string; runFromText?: (text: string, params: Record) => Promise | PixelImage; preview?: ( img: PixelImage, params: Record ) => Promise | PixelImage; popularity?: number; icon?: string; resultType?: 'image' | 'info' | 'text'; output?: OutputFormat; /** Инструменту нужен DOM (canvas): исполняется только напрямую, без воркера. */ domOnly?: boolean; /** Инструменту нужна вторая картинка-источник (знак), загружаемая на странице. */ needsOverlaySource?: boolean; }; export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' }; export function isChainable(tool: ToolEntry): boolean { return (tool.resultType ?? 'image') === 'image' && (!tool.sourceMode || tool.sourceMode === 'file'); } 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 ]; } 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: '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', 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: '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: '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-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: 'text', 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: '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'; } } } ]; import { TOOL_ICONS } from './tools/tool-icons'; import { TOOL_POPULARITY } from './tools/tool-popularity'; for (const entry of TOOLS) { entry.icon = entry.id; entry.popularity = TOOL_POPULARITY[entry.id] ?? 50; } export function getTool(id: string): ToolEntry | undefined { return TOOLS.find((tool) => tool.id === id); } export function defaultParams(tool: ToolEntry): Record { return Object.fromEntries(tool.params.map((p) => [p.id, p.default])); } export function sanitizeParams( tool: ToolEntry, values: Record ): Record { const out: Record = {}; for (const param of tool.params) { const raw = values[param.id]; switch (param.type) { case 'number': case 'slider': { const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : param.default; out[param.id] = clampRange(n, param.min, param.max); break; } case 'select': out[param.id] = typeof raw === 'string' && param.options.some((o) => o.value === raw) ? raw : param.default; break; case 'checkbox': out[param.id] = typeof raw === 'boolean' ? raw : param.default; break; case 'color': out[param.id] = typeof raw === 'string' && /^#[0-9a-f]{6}$/i.test(raw) ? raw : param.default; break; case 'text': out[param.id] = typeof raw === 'string' ? raw : param.default; break; } } return out; } function clampRange(value: number, min?: number, max?: number): number { if (min !== undefined && value < min) return min; if (max !== undefined && value > max) return max; return value; } export function outputOf(tool: ToolEntry): OutputFormat | undefined { if (tool.resultType === 'info') return undefined; return tool.output ?? PNG_OUTPUT; }