import { rotateFreeImage, skewImage, transformImage, zoomImage, } from "../../core/affine"; import { ToolError } from "../../core/errors"; import { centerByAlpha, changeCanvasSize, crop, cropToRatio, expandCanvas, flip, forceOrientation, padToRatio, resize, rotate90, splitToParts, splitToTileSize, symmetricCopy, tile, trimToContent, type Anchor9, type FlipAxis, } from "../../core/geometry"; import { renderPredicateMask } from "../../core/masks"; import { field, toolSchema, type Dimension } from "../../registry-schema"; import { imgTool, requireSource, type Tool, type ToolImageFile, } from "../types"; interface AddBorderParams { thickness: number; color: string; } export const addBorderSchema = toolSchema({ thickness: field.number({ label: "fields.thickness", min: 1, max: 500, step: 1, default: 5, }), color: field.color({ label: "fields.borderColor", default: "#000000" }), }); const addBorder: Tool = { id: "add-border", schema: addBorderSchema, input: "image", run: imgTool((img, p) => expandCanvas( img, p.thickness, p.thickness, p.thickness, p.thickness, p.color, ), ), }; interface FitOnBackgroundParams { size: Dimension; opacity: number; color: string; } export const fitOnBackgroundSchema = toolSchema( { size: field.dimension({ label: "fields.canvasSize", min: 1, max: 20000, width: 800, height: 600, presets: true, }), opacity: field.slider({ label: "fields.opacity", min: 0, max: 100, step: 1, default: 100, }), color: field.color({ label: "fields.background", default: "#ffffff" }), }, { layout: { groups: [ { title: "groups.canvas", fields: ["size"] }, { title: "groups.background", fields: ["opacity", "color"] }, ], }, }, ); const fitOnBackground: Tool = { id: "fit-on-background", schema: fitOnBackgroundSchema, input: "image", run: imgTool((img, p) => { const width = Math.trunc(p.size.width); const height = Math.trunc(p.size.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.color, Math.round((Math.trunc(p.opacity) / 100) * 255), ); }), resultNote: (p, result) => result.width !== Math.trunc(p.size.width) || result.height !== Math.trunc(p.size.height) ? { key: "resultCard.grown", tone: "info" } : null, }; interface ChangeCanvasSizeParams { size: Dimension; anchor: Anchor9; } export const changeCanvasSizeSchema = toolSchema( { size: field.dimension({ label: "fields.canvasSize", min: 1, max: 20000, width: 800, height: 600, presets: true, }), anchor: field.select({ label: "fields.anchor", 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" }, ], }), }, { layout: { groups: [ { title: "groups.canvas", fields: ["size"] }, { title: "groups.anchor", fields: ["anchor"] }, ], }, }, ); const changeCanvasSizeTool: Tool = { id: "change-canvas-size", schema: changeCanvasSizeSchema, input: "image", run: imgTool((img, p) => changeCanvasSize( img, Math.trunc(p.size.width), Math.trunc(p.size.height), p.anchor, ), ), }; interface ResizeParams { size: Dimension; keepAspect: boolean; } export const resizeSchema = toolSchema( { size: field.dimension({ label: "fields.canvasSize", min: 0, max: 20000, width: 1, height: 1, defaultFromSource: true, lockAspectWith: "keepAspect", presets: true, }), keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }), }, { layout: { groups: [ { title: "groups.canvas", fields: ["size"] }, { title: "groups.scaling", fields: ["keepAspect"] }, ], }, }, ); const resizeTool: Tool = { id: "resize", schema: resizeSchema, input: "image", run: imgTool((img, p) => { const keepAspect = p.keepAspect; let w = Math.trunc(p.size.width); let h = Math.trunc(p.size.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); }), resultNote: (p, result) => p.keepAspect && (result.width !== Math.trunc(p.size.width) || result.height !== Math.trunc(p.size.height)) ? { key: "resultCard.aspectAdjusted", tone: "info" } : null, }; interface CropParams { x: number; y: number; size: Dimension; } export const cropSchema = toolSchema( { x: field.number({ label: "fields.x", min: 0, max: 20000, step: 1, default: 0, maxFromSource: "width", maxMinus: 1, }), y: field.number({ label: "fields.y", min: 0, max: 20000, step: 1, default: 0, maxFromSource: "height", maxMinus: 1, }), size: field.dimension({ label: "fields.cropAreaSize", min: 1, max: 20000, width: 1, height: 1, defaultFromSource: true, maxFromSource: true, presets: true, }), }, { layout: { groups: [ { title: "groups.offset", fields: ["x", "y"] }, { title: "groups.cropArea", fields: ["size"] }, ], }, }, ); const cropTool: Tool = { id: "crop", schema: cropSchema, input: "image", run: imgTool((img, p) => { const w = Math.trunc(p.size.width); const h = Math.trunc(p.size.height); if (w <= 0 || h <= 0) { throw new ToolError("errors.cropSize"); } return crop(img, Math.trunc(p.x), Math.trunc(p.y), w, h); }), resultNote: (p, result) => result.width < Math.trunc(p.size.width) || result.height < Math.trunc(p.size.height) ? { key: "resultCard.clamped" } : null, }; interface RotateParams { angle: "90" | "180" | "270"; } export const rotateSchema = toolSchema({ angle: field.select({ label: "fields.angle", default: "90", options: [ { value: "90", label: "90° clockwise" }, { value: "180", label: "180°" }, { value: "270", label: "270° clockwise" }, ], }), }); const rotateTool: Tool = { id: "rotate", schema: rotateSchema, input: "image", run: imgTool((img, p) => rotate90(img, Number(p.angle) / 90)), }; interface FlipParams { axis: FlipAxis; } export const flipSchema = toolSchema({ axis: field.select({ label: "fields.axis", default: "horizontal", options: [ { value: "horizontal", label: "Horizontal (left to right)" }, { value: "vertical", label: "Vertical (top to bottom)" }, ], }), }); const flipTool: Tool = { id: "flip", schema: flipSchema, input: "image", run: imgTool((img, p) => flip(img, p.axis)), }; interface AddPaddingParams { padding: number; opacity: number; color: string; } export const addPaddingSchema = toolSchema( { padding: field.number({ label: "fields.padding", min: 1, max: 2000, step: 1, default: 10, }), opacity: field.slider({ label: "fields.opacity", min: 0, max: 100, step: 1, default: 0, }), color: field.color({ label: "fields.fillColor", default: "#ffffff" }), }, { layout: { groups: [ { title: "groups.padding", fields: ["padding"] }, { title: "groups.fill", fields: ["opacity", "color"] }, ], }, }, ); const addPaddingTool: Tool = { id: "add-padding", schema: addPaddingSchema, input: "image", run: imgTool((img, p) => expandCanvas( img, p.padding, p.padding, p.padding, p.padding, p.color, Math.round((Math.trunc(p.opacity) / 100) * 255), ), ), }; interface TileParams { columns: number; rows: number; } export const tileSchema = toolSchema({ columns: field.number({ label: "fields.columns", min: 1, max: 50, step: 1, default: 2, }), rows: field.number({ label: "fields.rows", min: 1, max: 50, step: 1, default: 2, }), }); const tileTool: Tool = { id: "tile", schema: tileSchema, input: "image", run: imgTool((img, p) => tile(img, p.columns, p.rows)), }; const MAX_SPLIT_PARTS = 1024; interface SplitPartsParams { mode: "grid" | "tile"; columns: number; rows: number; tile: Dimension; } export const splitPartsSchema = toolSchema( { mode: field.segmented({ label: "fields.splitMode", default: "grid", options: [ { value: "grid", label: "Grid" }, { value: "tile", label: "Tile size" }, ], }), columns: field.number({ label: "fields.columns", min: 1, max: 32, step: 1, default: 2, visibleWhen: { field: "mode", equals: "grid" }, }), rows: field.number({ label: "fields.rows", min: 1, max: 32, step: 1, default: 2, visibleWhen: { field: "mode", equals: "grid" }, }), tile: field.dimension({ label: "fields.tileSize", min: 1, max: 20000, width: 32, height: 32, presets: true, visibleWhen: { field: "mode", equals: "tile" }, }), }, { layout: { groups: [ { title: "groups.splitMode", fields: ["mode"] }, { title: "groups.grid", fields: ["columns", "rows"] }, { title: "groups.tileSize", fields: ["tile"] }, ], }, }, ); const splitPartsTool: Tool = { id: "split-into-parts", schema: splitPartsSchema, input: "image", result: "files", run: (ctx) => { const img = requireSource(ctx); const params = ctx.params as SplitPartsParams; const cols = params.mode === "tile" ? Math.max( 1, Math.ceil(img.width / Math.max(1, Math.trunc(params.tile.width))), ) : Math.max(1, Math.trunc(params.columns)); const rowsCount = params.mode === "tile" ? Math.max( 1, Math.ceil(img.height / Math.max(1, Math.trunc(params.tile.height))), ) : Math.max(1, Math.trunc(params.rows)); const count = cols * rowsCount; if (count > MAX_SPLIT_PARTS) { throw new ToolError("errors.tooManyParts", { count, max: MAX_SPLIT_PARTS, }); } const parts = params.mode === "tile" ? splitToTileSize(img, params.tile.width, params.tile.height) : splitToParts(img, cols, rowsCount); const rowLen = String(rowsCount).length; const colLen = String(cols).length; const files: ToolImageFile[] = parts.map((image, index) => { const col = (index % cols) + 1; const row = Math.floor(index / cols) + 1; return { name: `part-${String(row).padStart(rowLen, "0")}-${String(col).padStart(colLen, "0")}.png`, image, }; }); return { files }; }, }; interface EmptyParams {} export const centerByAlphaSchema = toolSchema({}); const centerByAlphaTool: Tool = { id: "center-by-alpha", schema: centerByAlphaSchema, input: "image", run: imgTool((img) => centerByAlpha(img)), runMask: (ctx) => renderPredicateMask(requireSource(ctx), (_r, _g, _b, a) => a > 0, { mode: "binary", }), }; interface SkewParams { degX: number; degY: number; } export const skewSchema = toolSchema({ degX: field.slider({ label: "fields.skewX", min: -80, max: 80, step: 1, default: 0, }), degY: field.slider({ label: "fields.skewY", min: -80, max: 80, step: 1, default: 0, }), }); const skewTool: Tool = { id: "skew", schema: skewSchema, input: "image", run: imgTool((img, p) => skewImage(img, p.degX, p.degY)), }; interface RotateFreeParams { angle: number; } export const rotateFreeSchema = toolSchema({ angle: field.slider({ label: "fields.angle", min: -180, max: 180, step: 1, default: 15, }), }); const rotateFreeTool: Tool = { id: "rotate-free", schema: rotateFreeSchema, input: "image", run: imgTool((img, p) => rotateFreeImage(img, p.angle)), }; interface ZoomParams { scale: number; } export const zoomSchema = toolSchema({ scale: field.slider({ label: "fields.scalePct", min: 100, max: 500, step: 10, default: 200, }), }); const zoomTool: Tool = { id: "zoom", schema: zoomSchema, input: "image", run: imgTool((img, p) => zoomImage(img, p.scale)), }; interface TrimEmptySpaceParams { threshold: number; } export const trimEmptySpaceSchema = toolSchema({ threshold: field.slider({ label: "fields.alphaThreshold", min: 0, max: 254, step: 1, default: 0, }), }); const trimEmptySpaceTool: Tool = { id: "trim-empty-space", schema: trimEmptySpaceSchema, input: "image", run: imgTool((img, p) => trimToContent(img, p.threshold)), runMask: (ctx) => renderPredicateMask( requireSource(ctx), (_r, _g, _b, a) => a > ctx.params.threshold, { mode: "binary" }, ), }; type AspectRatio = "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "16:9" | "9:16"; interface ChangeAspectRatioParams { ratio: AspectRatio; mode: "crop" | "pad"; } export const changeAspectRatioSchema = toolSchema({ ratio: field.select({ label: "fields.ratio", 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" }, ], }), mode: field.select({ label: "fields.fitMode", default: "crop", options: [ { value: "crop", label: "Crop to fill" }, { value: "pad", label: "Pad to fit" }, ], }), }); const changeAspectRatioTool: Tool = { id: "change-aspect-ratio", schema: changeAspectRatioSchema, input: "image", run: imgTool((img, p) => { const [rw, rh] = p.ratio.split(":").map(Number); const ratio = rw / rh; return p.mode === "pad" ? padToRatio(img, ratio) : cropToRatio(img, ratio); }), }; interface SwapOrientationParams { target: "portrait" | "landscape"; } export const swapOrientationSchema = toolSchema({ target: field.select({ label: "fields.orientation", default: "portrait", options: [ { value: "portrait", label: "Portrait" }, { value: "landscape", label: "Landscape" }, ], }), }); const swapOrientationTool: Tool = { id: "swap-orientation", schema: swapOrientationSchema, input: "image", run: imgTool((img, p) => forceOrientation(img, p.target)), }; type SymmetricAxis = "horizontal" | "vertical"; type KeepSide = "left" | "right" | "top" | "bottom"; interface SymmetricCopyParams { axis: SymmetricAxis; keepSide: KeepSide; } export const symmetricCopySchema = toolSchema({ axis: field.select({ label: "fields.axis", default: "vertical", options: [ { value: "vertical", label: "Vertical (double width)" }, { value: "horizontal", label: "Horizontal (double height)" }, ], }), keepSide: field.select({ label: "fields.keepSide", default: "left", options: [ { value: "left", label: "Left" }, { value: "right", label: "Right" }, { value: "top", label: "Top" }, { value: "bottom", label: "Bottom" }, ], }), }); const symmetricCopyTool: Tool = { id: "symmetric-copy", schema: symmetricCopySchema, input: "image", run: imgTool((img, p) => symmetricCopy(img, p.axis, p.keepSide)), }; interface ShiftParams { offsetX: number; offsetY: number; color: string; } export const shiftSchema = toolSchema({ offsetX: field.number({ label: "fields.offsetX", min: -5000, max: 5000, step: 1, default: 0, }), offsetY: field.number({ label: "fields.offsetY", min: -5000, max: 5000, step: 1, default: 0, }), color: field.color({ label: "fields.fillColor", default: "#ffffff" }), }); const shiftTool: Tool = { id: "shift", schema: shiftSchema, input: "image", run: imgTool((img, p) => transformImage( img, [1, 0, 0, 1, -Math.trunc(p.offsetX), -Math.trunc(p.offsetY)], img.width, img.height, p.color, ), ), }; export const geometryTools = [ addBorder, fitOnBackground, changeCanvasSizeTool, resizeTool, cropTool, rotateTool, flipTool, addPaddingTool, tileTool, centerByAlphaTool, skewTool, rotateFreeTool, zoomTool, trimEmptySpaceTool, changeAspectRatioTool, swapOrientationTool, symmetricCopyTool, shiftTool, splitPartsTool, ];