refactor: separate registry file

This commit is contained in:
2026-09-04 17:07:38 +05:00
parent 1c746299c9
commit 58e75f2334
11 changed files with 4043 additions and 3773 deletions
+25
View File
@@ -0,0 +1,25 @@
import { ToolError } from "./core/errors";
export function num(params: Record<string, unknown>, 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<string, unknown>, id: string): string {
const v = params[id];
if (typeof v !== "string") {
throw new ToolError("errors.paramString", { id });
}
return v;
}
export function bool(params: Record<string, unknown>, id: string): boolean {
const v = params[id];
if (typeof v !== "boolean") {
throw new ToolError("errors.paramBool", { id });
}
return v;
}
+23
View File
@@ -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<P = Record<string, unknown>> = {
id: string;
title: string;
description: string;
category: CategoryId;
schema: ToolSchema<P>;
run: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
generate?: (params: P) => Promise<PixelImage> | PixelImage;
toText?: (img: PixelImage, params: P) => Promise<string> | string;
runFromText?: (text: string, params: P) => Promise<PixelImage> | PixelImage;
textToText?: (text: string) => Promise<string> | string;
preview?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
icon?: string;
};
+42 -3773
View File
File diff suppressed because it is too large Load Diff
+686
View File
@@ -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<AddStrokeParams>({
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"),
});
},
},
];
}
+289
View File
@@ -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<string, unknown>,
) => (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";
}
},
},
];
}
+501
View File
@@ -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 greenmagenta / blueyellow 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 2256 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 FloydSteinberg 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: "FloydSteinberg" },
{ value: "bayer", label: "Bayer 4×4" },
],
},
],
run: (img, p) =>
ditherImage(
img,
num(p, "colors"),
str(p, "pattern") === "bayer" ? "bayer" : "floyd-steinberg",
),
},
];
}
+354
View File
@@ -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<string, unknown>, "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<string, unknown>, "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<number | null> => {
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),
},
];
}
+209
View File
@@ -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")),
},
];
}
+942
View File
@@ -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 0360° 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",
),
},
];
}
+657
View File
@@ -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<AddBorderParams>({
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"),
),
},
];
}
+315
View File
@@ -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"),
}),
},
];
}