mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
658 lines
14 KiB
TypeScript
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"),
|
|
),
|
|
},
|
|
];
|
|
}
|