Files
easy-png-tools2/web/src/lib/registry/geometry.ts
T

658 lines
14 KiB
TypeScript

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"),
),
},
];
}