From e7ffb37e981c553cf17db88155ac12667f104062 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Tue, 21 Jul 2026 16:48:48 +0800 Subject: [PATCH] feat(image): add rounded-square and squircle crop tool (#602) Adds a Rounded Crop image tool for logo, favicon, and app-icon work. It masks the framed square to a rounded rectangle (with a corner-radius control) or an iOS-style squircle, reusing circle-crop's zoom/offset framing, border ring, background fill, and output-size options. Includes translations across all 21 locales. Closes #601 --- apps/api/src/openapi.yaml | 53 +++ apps/api/src/routes/tools/index.ts | 2 + apps/api/src/routes/tools/rounded-crop.ts | 137 +++++++ apps/docs/guide/architecture.md | 2 +- apps/docs/guide/getting-started.md | 2 +- apps/landing/public/_redirects | 2 + .../tools/rounded-crop-settings.tsx | 356 ++++++++++++++++++ apps/web/src/lib/icon-map.ts | 2 + apps/web/src/lib/tool-display-modes.ts | 1 + apps/web/src/lib/tool-registry.tsx | 6 + packages/shared/src/constants.ts | 21 ++ packages/shared/src/i18n/ar.ts | 9 + packages/shared/src/i18n/de.ts | 10 + packages/shared/src/i18n/en.ts | 9 + packages/shared/src/i18n/es.ts | 10 + packages/shared/src/i18n/fr.ts | 9 + packages/shared/src/i18n/hi.ts | 9 + packages/shared/src/i18n/id.ts | 9 + packages/shared/src/i18n/it.ts | 10 + packages/shared/src/i18n/ja.ts | 9 + packages/shared/src/i18n/ko.ts | 9 + packages/shared/src/i18n/nl.ts | 10 + packages/shared/src/i18n/pl.ts | 9 + packages/shared/src/i18n/pt-BR.ts | 10 + packages/shared/src/i18n/ru.ts | 9 + packages/shared/src/i18n/sv.ts | 9 + packages/shared/src/i18n/th.ts | 9 + packages/shared/src/i18n/tr.ts | 9 + packages/shared/src/i18n/uk.ts | 9 + packages/shared/src/i18n/vi.ts | 9 + packages/shared/src/i18n/zh-CN.ts | 9 + packages/shared/src/i18n/zh-TW.ts | 9 + tests/integration/platform/docs.test.ts | 6 +- .../tools/image/rounded-crop.test.ts | 155 ++++++++ tests/unit/shared/section.test.ts | 2 +- 35 files changed, 935 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/routes/tools/rounded-crop.ts create mode 100644 apps/web/src/components/tools/rounded-crop-settings.tsx create mode 100644 tests/integration/tools/image/rounded-crop.test.ts diff --git a/apps/api/src/openapi.yaml b/apps/api/src/openapi.yaml index af4dccc6..d974443d 100644 --- a/apps/api/src/openapi.yaml +++ b/apps/api/src/openapi.yaml @@ -11822,6 +11822,59 @@ paths: schema: $ref: "#/components/schemas/UnauthorizedError" + /api/v1/tools/image/rounded-crop: + post: + operationId: roundedCrop + tags: [Tools] + summary: Rounded crop + description: Crop an image to a rounded square or squircle with transparent corners, optional border, and adjustable zoom/offset. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Image file to process + settings: + type: string + description: | + JSON string with options: + - `shape` (string, default "rounded-square") - "rounded-square" or "squircle" + - `cornerRadius` (number 0-50, default 25) - Corner radius as a percent of the shorter side (rounded-square only) + - `zoom` (number 1-5, default 1) - Zoom factor (>=1 crops tighter) + - `offsetX` (number 0-1, default 0.5) - Horizontal center position + - `offsetY` (number 0-1, default 0.5) - Vertical center position + - `borderWidth` (integer 0-200, default 0) - Border width in pixels + - `borderColor` (string, default "#ffffff") - Border hex color + - `background` (string, default "transparent") - "transparent" or a hex color for corners + - `outputSize` (integer 16-4096, optional) - Final square dimension in pixels + responses: + "200": + description: Processed image + content: + application/json: + schema: + $ref: "#/components/schemas/ToolResponse" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + /api/v1/tools/image/duotone: post: operationId: duotone diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index abb60744..c6f85f99 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -124,6 +124,7 @@ import { registerRingtoneMaker } from "./ringtone-maker.js"; import { registerRotate } from "./rotate.js"; import { registerRotatePdf } from "./rotate-pdf.js"; import { registerRotateVideo } from "./rotate-video.js"; +import { registerRoundedCrop } from "./rounded-crop.js"; import { registerSharpening } from "./sharpening.js"; import { registerSignPdf } from "./sign-pdf.js"; import { registerSilenceRemoval } from "./silence-removal.js"; @@ -235,6 +236,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "border", register: registerBorder }, { id: "beautify", register: registerBeautify }, { id: "circle-crop", register: registerCircleCrop }, + { id: "rounded-crop", register: registerRoundedCrop }, { id: "duotone", register: registerDuotone }, { id: "histogram", register: registerHistogram }, { id: "image-pad", register: registerImagePad }, diff --git a/apps/api/src/routes/tools/rounded-crop.ts b/apps/api/src/routes/tools/rounded-crop.ts new file mode 100644 index 00000000..00290d65 --- /dev/null +++ b/apps/api/src/routes/tools/rounded-crop.ts @@ -0,0 +1,137 @@ +import type { FastifyInstance } from "fastify"; +import sharp, { type OverlayOptions } from "sharp"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + // Which shape to mask to: a rounded rectangle or an iOS-style squircle. + shape: z.enum(["rounded-square", "squircle"]).default("rounded-square"), + // Corner radius as a percent of the shorter side (0 = square, 50 = circle). + // Only used for the "rounded-square" shape; the squircle is a fixed curve. + cornerRadius: z.number().min(0).max(50).default(25), + // Framing: zoom (>=1 crops tighter) + where the box sits in the image (0..1). + zoom: z.number().min(1).max(5).default(1), + offsetX: z.number().min(0).max(1).default(0.5), + offsetY: z.number().min(0).max(1).default(0.5), + // Styling. + borderWidth: z.number().int().min(0).max(200).default(0), + borderColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default("#ffffff"), + // "transparent" leaves the corners clear; a hex fills them. + background: z + .string() + .regex(/^(transparent|#[0-9a-fA-F]{6})$/) + .default("transparent"), + // Final output dimension in px (square). Omitted = native size. + outputSize: z.number().int().min(16).max(4096).optional(), +}); + +type Shape = z.infer["shape"]; + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + return { + r: Number.parseInt(hex.slice(1, 3), 16), + g: Number.parseInt(hex.slice(3, 5), 16), + b: Number.parseInt(hex.slice(5, 7), 16), + }; +} + +// Superellipse (Lamé curve) |x/a|^n + |y/a|^n = 1 sampled into an SVG polygon. +// n = 4 gives the rounded-but-square "squircle" look. +const SQUIRCLE_N = 4; +const SQUIRCLE_SAMPLES = 128; + +function squirclePath(size: number): string { + const a = size / 2; + const exp = 2 / SQUIRCLE_N; + const pts: string[] = []; + for (let i = 0; i < SQUIRCLE_SAMPLES; i++) { + const t = (i / SQUIRCLE_SAMPLES) * 2 * Math.PI; + const c = Math.cos(t); + const s = Math.sin(t); + const x = a + a * Math.sign(c) * Math.abs(c) ** exp; + const y = a + a * Math.sign(s) * Math.abs(s) ** exp; + pts.push(`${x.toFixed(2)},${y.toFixed(2)}`); + } + return `M${pts.join("L")}Z`; +} + +// A single-shape SVG the size of `size`, filled with `fill`. `radiusPx` only +// applies to the rounded rectangle. +function shapeSvg(shape: Shape, size: number, radiusPx: number, fill: string): Buffer { + const body = + shape === "squircle" + ? `` + : ``; + return Buffer.from(`${body}`); +} + +export function registerRoundedCrop(app: FastifyInstance) { + createToolRoute(app, { + toolId: "rounded-crop", + settingsSchema, + process: async (inputBuffer, settings, filename) => { + const meta = await sharp(inputBuffer).metadata(); + const W = meta.width ?? 1; + const H = meta.height ?? 1; + + // The crop box, derived from zoom + offsets. + let d = Math.round(Math.min(W, H) / settings.zoom); + d = Math.max(8, Math.min(d, W, H)); + let left = Math.round((W - d) * settings.offsetX); + let top = Math.round((H - d) * settings.offsetY); + left = Math.max(0, Math.min(left, W - d)); + top = Math.max(0, Math.min(top, H - d)); + + const bw = Math.min(settings.borderWidth, Math.floor(d / 2)); + const canvas = d + 2 * bw; + // Percent of the shorter side, capped at half (a full round = circle). + const radiusPx = Math.min((settings.cornerRadius / 100) * d, d / 2); + + // Extract the square, mask it to the chosen shape. + const squareBuf = await sharp(inputBuffer) + .extract({ left, top, width: d, height: d }) + .toBuffer(); + const mask = shapeSvg(settings.shape, d, radiusPx, "#fff"); + const imgShape = await sharp(squareBuf) + .ensureAlpha() + .composite([{ input: mask, blend: "dest-in" }]) + .png() + .toBuffer(); + + // Compose: background, optional border, then the masked image on top. + const bg = + settings.background === "transparent" + ? { r: 0, g: 0, b: 0, alpha: 0 } + : { ...hexToRgb(settings.background), alpha: 1 }; + const layers: OverlayOptions[] = []; + if (bw > 0) { + // Outward offset of a rounded rect keeps straight edges and grows the + // corner radius by the border width; the squircle border is the same + // curve scaled to the outer canvas. + const ring = shapeSvg(settings.shape, canvas, radiusPx + bw, settings.borderColor); + layers.push({ input: ring, left: 0, top: 0 }); + } + layers.push({ input: imgShape, left: bw, top: bw }); + + let out = await sharp({ + create: { width: canvas, height: canvas, channels: 4, background: bg }, + }) + .composite(layers) + .png() + .toBuffer(); + + if (settings.outputSize) { + out = await sharp(out) + .resize(settings.outputSize, settings.outputSize, { fit: "fill" }) + .png() + .toBuffer(); + } + + const base = filename.replace(/\.[^.]+$/, ""); + return { buffer: out, filename: `${base}_rounded.png`, contentType: "image/png" }; + }, + }); +} diff --git a/apps/docs/guide/architecture.md b/apps/docs/guide/architecture.md index 3cad842a..fa23b9d2 100644 --- a/apps/docs/guide/architecture.md +++ b/apps/docs/guide/architecture.md @@ -49,7 +49,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an ### API (`apps/api`) {#api-apps-api} -A Fastify v5 server exposing 242 tool routes across five modalities (image, video, audio, PDF, file) that handles: +A Fastify v5 server exposing 243 tool routes across five modalities (image, video, audio, PDF, file) that handles: - File uploads, temporary workspace management, and persistent file storage - User file library (`user_files` table): a saved edit is stored as an independent new file by default, or as a parent-linked version when you overwrite the original. It records which tools were applied (`toolChain`) and gets an auto-generated thumbnail for the Files page - Tool execution (routes each tool request to the image engine or AI bridge) diff --git a/apps/docs/guide/getting-started.md b/apps/docs/guide/getting-started.md index a3f3eb46..216b7501 100644 --- a/apps/docs/guide/getting-started.md +++ b/apps/docs/guide/getting-started.md @@ -127,7 +127,7 @@ pnpm dev | Modality | Count | Example Tools | |----------|-------|---------------| -| **Image** | 106 | Resize, Crop, Compress, Convert, Remove Background, Upscale, OCR, Watermark, Collage, Colorize, GIF Tools, format presets | +| **Image** | 107 | Resize, Crop, Compress, Convert, Remove Background, Upscale, OCR, Watermark, Collage, Colorize, GIF Tools, format presets | | **Video** | 57 | Trim, Crop, Compress, Convert, Merge, Extract Audio, Auto Subtitles, Video to GIF, Resize, Stabilize, format presets | | **Audio** | 27 | Trim, Merge, Convert, Normalize, Noise Reduction, Transcribe, Pitch Shift, Fade, Ringtone Maker, format presets | | **PDF / Document** | 42 | Merge, Split, Compress, OCR, Watermark, Redact, Word to PDF, Excel to PDF, Rotate, Protect, Repair | diff --git a/apps/landing/public/_redirects b/apps/landing/public/_redirects index 848f0c7e..e706c82b 100644 --- a/apps/landing/public/_redirects +++ b/apps/landing/public/_redirects @@ -113,6 +113,8 @@ /tools/beautify /tools/image/beautify/ 301 /tools/circle-crop/ /tools/image/circle-crop/ 301 /tools/circle-crop /tools/image/circle-crop/ 301 +/tools/rounded-crop/ /tools/image/rounded-crop/ 301 +/tools/rounded-crop /tools/image/rounded-crop/ 301 /tools/duotone/ /tools/image/duotone/ 301 /tools/duotone /tools/image/duotone/ 301 /tools/image-pad/ /tools/image/image-pad/ 301 diff --git a/apps/web/src/components/tools/rounded-crop-settings.tsx b/apps/web/src/components/tools/rounded-crop-settings.tsx new file mode 100644 index 00000000..a85a7524 --- /dev/null +++ b/apps/web/src/components/tools/rounded-crop-settings.tsx @@ -0,0 +1,356 @@ +import { Download } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { useFileStore } from "@/stores/file-store"; + +const PREVIEW_D = 200; // crop box side in the inline preview, px +type Shape = "rounded-square" | "squircle"; + +// CSS clip-path polygon tracing the same superellipse the backend renders, +// so the squircle preview matches the output. Kept module-level: it never +// changes, so there's nothing to recompute per render. +const SQUIRCLE_CLIP = (() => { + const n = 4; + const exp = 2 / n; + const pts: string[] = []; + for (let i = 0; i < 64; i++) { + const t = (i / 64) * 2 * Math.PI; + const c = Math.cos(t); + const s = Math.sin(t); + const x = 50 + 50 * Math.sign(c) * Math.abs(c) ** exp; + const y = 50 + 50 * Math.sign(s) * Math.abs(s) ** exp; + pts.push(`${x.toFixed(2)}% ${y.toFixed(2)}%`); + } + return `polygon(${pts.join(",")})`; +})(); + +export function RoundedCropSettings() { + const { t } = useTranslation(); + const { files } = useFileStore(); + const entry = useFileStore((s) => s.entries[s.selectedIndex]); + const blobUrl = entry?.blobUrl; + const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = + useToolProcessor("rounded-crop"); + + const [dims, setDims] = useState<{ w: number; h: number } | null>(null); + const [shape, setShape] = useState("rounded-square"); + const [cornerRadius, setCornerRadius] = useState(25); + const [zoom, setZoom] = useState(1); + const [offsetX, setOffsetX] = useState(0.5); + const [offsetY, setOffsetY] = useState(0.5); + const [borderWidth, setBorderWidth] = useState(0); + const [borderColor, setBorderColor] = useState("#ffffff"); + const [bgMode, setBgMode] = useState<"transparent" | "color">("transparent"); + const [bgColor, setBgColor] = useState("#ffffff"); + const [outputSize, setOutputSize] = useState(""); + + // Natural image dimensions, for accurate framing math. + useEffect(() => { + if (!blobUrl) { + setDims(null); + return; + } + const img = new Image(); + img.onload = () => setDims({ w: img.naturalWidth, h: img.naturalHeight }); + img.src = blobUrl; + }, [blobUrl]); + + // Preview geometry (mirrors the backend region math). + const W = dims?.w ?? 1; + const H = dims?.h ?? 1; + const d = Math.max(1, Math.min(W, H) / zoom); + const scale = PREVIEW_D / d; + const left = (W - d) * offsetX; + const top = (H - d) * offsetY; + const bwPx = borderWidth * scale; + // Corner radius in preview px, capped at half (a full round = circle). + const innerRadius = Math.min((cornerRadius / 100) * PREVIEW_D, PREVIEW_D / 2); + + // Shape styling for the inner crop box and outer border ring. + const innerShape = useMemo( + () => + shape === "squircle" ? { clipPath: SQUIRCLE_CLIP } : { borderRadius: `${innerRadius}px` }, + [shape, innerRadius], + ); + const outerShape = + shape === "squircle" + ? { clipPath: SQUIRCLE_CLIP } + : { borderRadius: `${innerRadius + bwPx}px` }; + + // Drag-to-pan the crop box. + const drag = useRef<{ px: number; py: number; ox: number; oy: number } | null>(null); + const onPointerDown = (e: React.PointerEvent) => { + if (!dims) return; + e.currentTarget.setPointerCapture(e.pointerId); + drag.current = { px: e.clientX, py: e.clientY, ox: offsetX, oy: offsetY }; + }; + const onPointerMove = (e: React.PointerEvent) => { + if (!drag.current || !dims) return; + const dx = e.clientX - drag.current.px; + const dy = e.clientY - drag.current.py; + const rangeX = W - d; + const rangeY = H - d; + if (rangeX > 0) { + const nl = Math.min(Math.max(drag.current.ox * rangeX - dx / scale, 0), rangeX); + setOffsetX(nl / rangeX); + } + if (rangeY > 0) { + const nt = Math.min(Math.max(drag.current.oy * rangeY - dy / scale, 0), rangeY); + setOffsetY(nt / rangeY); + } + }; + const onPointerUp = () => { + drag.current = null; + }; + + const handleProcess = () => { + const settings: Record = { + shape, + cornerRadius, + zoom, + offsetX, + offsetY, + borderWidth, + borderColor, + background: bgMode === "transparent" ? "transparent" : bgColor, + }; + const sz = Number.parseInt(outputSize, 10); + if (!Number.isNaN(sz) && sz >= 16) settings.outputSize = sz; + if (files.length > 1) processAllFiles(files, settings); + else processFiles(files, settings); + }; + + const hasFile = files.length > 0; + + return ( +
+ {/* Live framing preview */} + {hasFile && blobUrl && ( +
+
0 ? borderColor : "transparent", + ...outerShape, + }} + > +
+ {/* checkerboard hint for transparency */} + {bgMode === "transparent" && ( +
+ )} + +
+
+
+ )} + {hasFile && ( +

+ Drag the preview to reposition +

+ )} + + {/* Shape */} +
+ Shape +
+ + +
+
+ + {/* Corner radius (rounded square only) */} + {shape === "rounded-square" && ( +
+
+ Corner radius + {cornerRadius}% +
+ setCornerRadius(Number(e.target.value))} + className="w-full mt-1" + /> +
+ )} + + {/* Zoom */} +
+
+ Zoom + {zoom.toFixed(1)}x +
+ setZoom(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Border */} +
+
+ Border + {borderWidth}px +
+
+ setBorderWidth(Number(e.target.value))} + className="flex-1 min-w-0" + /> + setBorderColor(e.target.value)} + aria-label="Border color" + className="h-7 w-9 shrink-0 rounded border border-border bg-background" + /> +
+
+ + {/* Background */} +
+ Background +
+
+ + +
+ {bgMode === "color" && ( + setBgColor(e.target.value)} + aria-label="Background color" + className="h-7 w-9 shrink-0 rounded border border-border bg-background" + /> + )} +
+
+ + {/* Output size */} +
+ + setOutputSize(e.target.value)} + placeholder="Original" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} + + {downloadUrl && ( + + + {t.common.download} + + )} +
+ ); +} diff --git a/apps/web/src/lib/icon-map.ts b/apps/web/src/lib/icon-map.ts index 95675dc8..7d84944d 100644 --- a/apps/web/src/lib/icon-map.ts +++ b/apps/web/src/lib/icon-map.ts @@ -94,6 +94,7 @@ import { SlidersHorizontal, Sparkles, Split, + Squircle, Stamp, Star, Table, @@ -208,6 +209,7 @@ export const ICON_MAP: Record = { SlidersHorizontal, Sparkles, Split, + Squircle, Stamp, Star, Table, diff --git a/apps/web/src/lib/tool-display-modes.ts b/apps/web/src/lib/tool-display-modes.ts index 5ba97d13..652de75b 100644 --- a/apps/web/src/lib/tool-display-modes.ts +++ b/apps/web/src/lib/tool-display-modes.ts @@ -61,6 +61,7 @@ export const TOOL_DISPLAY_MODES: Record = { border: "live-preview", beautify: "live-preview", "circle-crop": "live-preview", + "rounded-crop": "live-preview", duotone: "live-preview", "image-pad": "live-preview", pixelate: "live-preview", diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index d5d377f0..a590910f 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -845,6 +845,11 @@ const CircleCropSettings = lazy(() => default: m.CircleCropSettings, })), ); +const RoundedCropSettings = lazy(() => + import("@/components/tools/rounded-crop-settings").then((m) => ({ + default: m.RoundedCropSettings, + })), +); const DuotoneSettings = lazy(() => import("@/components/tools/duotone-settings").then((m) => ({ default: m.DuotoneSettings, @@ -969,6 +974,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [ ["border", { livePreview: true, Settings: BorderSettings as never }], ["beautify", { livePreview: true, Settings: BeautifySettings as never }], ["circle-crop", { livePreview: true, Settings: CircleCropSettings }], + ["rounded-crop", { livePreview: true, Settings: RoundedCropSettings }], ["duotone", { livePreview: true, Settings: DuotoneSettings }], ["image-pad", { livePreview: true, Settings: ImagePadSettings }], ["pixelate", { livePreview: true, Settings: PixelateSettings }], diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 29920c9a..9aea99cb 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -698,6 +698,27 @@ const BASE_TOOLS: Tool[] = [ acceptedInputs: IMAGE_INPUTS, executionHint: "fast", }, + { + id: "rounded-crop", + name: "Rounded Crop", + description: "Crop image to a rounded square or squircle with transparent corners", + category: "layout", + icon: "Squircle", + route: "/rounded-crop", + modality: "image", + acceptedInputs: IMAGE_INPUTS, + executionHint: "fast", + keywords: [ + "rounded corners", + "rounded square", + "squircle", + "favicon", + "app icon", + "logo", + "avatar", + "corner radius", + ], + }, { id: "duotone", name: "Duotone", diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index b24eaa0f..2fcc68fe 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -741,6 +741,10 @@ export const ar: TranslationKeys = { name: "اقتصاص دائري", description: "اقتصاص الصورة بشكل دائري في المنتصف مع زوايا شفافة", }, + "rounded-crop": { + name: "اقتصاص مستدير", + description: "اقتصاص الصورة إلى مربع مستدير أو squircle مع زوايا شفافة", + }, duotone: { name: "ثنائي اللون", description: "تطبيق تأثير ثنائي اللون مع ألوان ظلال وإبراز مخصصة", @@ -2121,6 +2125,11 @@ export const ar: TranslationKeys = { submitBatch: "اقتصاص دائري ({count} ملفات)", progressLabel: "جارٍ الاقتصاص الدائري", }, + "rounded-crop": { + submit: "اقتصاص مستدير", + submitBatch: "اقتصاص مستدير ({count} ملفات)", + progressLabel: "جارٍ الاقتصاص إلى شكل مستدير", + }, duotone: { shadow: "لون الظل", highlight: "لون الإبراز", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 0ba657b9..f3ee027a 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -749,6 +749,11 @@ export const de: TranslationKeys = { name: "Kreiszuschnitt", description: "Bild auf einen zentrierten Kreis mit transparenten Ecken zuschneiden", }, + "rounded-crop": { + name: "Abgerundeter Zuschnitt", + description: + "Bild auf ein abgerundetes Quadrat oder Squircle mit transparenten Ecken zuschneiden", + }, duotone: { name: "Duotone", description: "Zweifarbigen Duotone-Effekt mit eigenen Schatten- und Lichtfarben anwenden", @@ -2144,6 +2149,11 @@ export const de: TranslationKeys = { submitBatch: "Kreiszuschnitt ({count} Dateien)", progressLabel: "Wird kreisförmig zugeschnitten", }, + "rounded-crop": { + submit: "Abgerundeter Zuschnitt", + submitBatch: "Abgerundeter Zuschnitt ({count} Dateien)", + progressLabel: "Wird abgerundet zugeschnitten", + }, duotone: { shadow: "Schattenfarbe", highlight: "Lichtfarbe", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 54404d05..9177bec2 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -706,6 +706,10 @@ export const en = { name: "Circle Crop", description: "Crop image to a centered circle with transparent corners", }, + "rounded-crop": { + name: "Rounded Crop", + description: "Crop image to a rounded square or squircle with transparent corners", + }, duotone: { name: "Duotone", description: "Apply a two-color duotone effect with custom shadow and highlight colors", @@ -2085,6 +2089,11 @@ export const en = { submitBatch: "Circle Crop ({count} files)", progressLabel: "Cropping to circle", }, + "rounded-crop": { + submit: "Rounded Crop", + submitBatch: "Rounded Crop ({count} files)", + progressLabel: "Cropping to rounded shape", + }, duotone: { shadow: "Shadow Color", highlight: "Highlight Color", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index eb93f91f..bd72e020 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -733,6 +733,11 @@ export const es: TranslationKeys = { name: "Recorte circular", description: "Recorta la imagen en un círculo centrado con esquinas transparentes", }, + "rounded-crop": { + name: "Recorte redondeado", + description: + "Recorta la imagen en un cuadrado redondeado o squircle con esquinas transparentes", + }, duotone: { name: "Duotono", description: "Aplica un efecto duotono con colores personalizados de sombra y resaltado", @@ -2126,6 +2131,11 @@ export const es: TranslationKeys = { submitBatch: "Recorte circular ({count} archivos)", progressLabel: "Recortando en círculo", }, + "rounded-crop": { + submit: "Recorte redondeado", + submitBatch: "Recorte redondeado ({count} archivos)", + progressLabel: "Recortando a forma redondeada", + }, duotone: { shadow: "Color de sombra", highlight: "Color de resalte", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 8f366a09..aad8c77c 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -750,6 +750,10 @@ export const fr: TranslationKeys = { name: "Recadrage circulaire", description: "Recadrez l’image en cercle centré avec coins transparents", }, + "rounded-crop": { + name: "Recadrage arrondi", + description: "Recadrez l’image en carré arrondi ou squircle avec coins transparents", + }, duotone: { name: "Bichromie", description: @@ -2152,6 +2156,11 @@ export const fr: TranslationKeys = { submitBatch: "Recadrage circulaire ({count} fichiers)", progressLabel: "Recadrage en cercle", }, + "rounded-crop": { + submit: "Recadrage arrondi", + submitBatch: "Recadrage arrondi ({count} fichiers)", + progressLabel: "Recadrage en forme arrondie", + }, duotone: { shadow: "Couleur des ombres", highlight: "Couleur des hautes lumières", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index f3338cb1..5380cbe6 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -575,6 +575,10 @@ export const hi: TranslationKeys = { name: "गोल क्रॉप", description: "इमेज को पारदर्शी कोनों के साथ बीच में गोल क्रॉप करें", }, + "rounded-crop": { + name: "गोल कोनों वाला क्रॉप", + description: "इमेज को पारदर्शी कोनों के साथ गोल वर्ग या squircle में क्रॉप करें", + }, duotone: { name: "डुओटोन", description: "कस्टम शैडो और हाइलाइट रंगों के साथ दो-रंगी डुओटोन इफ़ेक्ट लागू करें", @@ -1951,6 +1955,11 @@ export const hi: TranslationKeys = { submitBatch: "सर्कल क्रॉप ({count} फ़ाइलें)", progressLabel: "सर्कल में क्रॉप हो रहा है", }, + "rounded-crop": { + submit: "गोल कोनों वाला क्रॉप", + submitBatch: "गोल कोनों वाला क्रॉप ({count} फ़ाइलें)", + progressLabel: "गोल आकार में क्रॉप हो रहा है", + }, duotone: { shadow: "शैडो रंग", highlight: "हाइलाइट रंग", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index b6008e7a..14fd55d4 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -748,6 +748,10 @@ export const id: TranslationKeys = { name: "Potong Lingkaran", description: "Potong gambar menjadi lingkaran di tengah dengan sudut transparan", }, + "rounded-crop": { + name: "Potong Membulat", + description: "Potong gambar menjadi persegi membulat atau squircle dengan sudut transparan", + }, duotone: { name: "Duotone", description: "Terapkan efek duotone dua warna dengan warna bayangan dan sorotan kustom", @@ -2135,6 +2139,11 @@ export const id: TranslationKeys = { submitBatch: "Crop Lingkaran ({count} file)", progressLabel: "Meng-crop menjadi lingkaran", }, + "rounded-crop": { + submit: "Crop Membulat", + submitBatch: "Crop Membulat ({count} file)", + progressLabel: "Meng-crop menjadi bentuk membulat", + }, duotone: { shadow: "Warna Bayangan", highlight: "Warna Sorotan", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 7076582e..b4cad765 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -748,6 +748,11 @@ export const it: TranslationKeys = { name: "Ritaglio Circolare", description: "Ritaglia l'immagine in un cerchio centrato con angoli trasparenti", }, + "rounded-crop": { + name: "Ritaglio Arrotondato", + description: + "Ritaglia l'immagine in un quadrato arrotondato o squircle con angoli trasparenti", + }, duotone: { name: "Duotono", description: "Applica un effetto duotono a due colori con ombre e luci personalizzate", @@ -2140,6 +2145,11 @@ export const it: TranslationKeys = { submitBatch: "Ritaglio Circolare ({count} file)", progressLabel: "Ritaglio in cerchio", }, + "rounded-crop": { + submit: "Ritaglio Arrotondato", + submitBatch: "Ritaglio Arrotondato ({count} file)", + progressLabel: "Ritaglio in forma arrotondata", + }, duotone: { shadow: "Colore Ombra", highlight: "Colore Luce", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 3331dc64..6039e6e4 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -715,6 +715,10 @@ export const ja: TranslationKeys = { name: "円形クロップ", description: "画像を中央の円形にクロップし、角を透明にする", }, + "rounded-crop": { + name: "角丸クロップ", + description: "画像を角丸の正方形または squircle にクロップし、角を透明にする", + }, duotone: { name: "デュオトーン", description: "カスタムのシャドウとハイライト色で2色デュオトーン効果を適用", @@ -2094,6 +2098,11 @@ export const ja: TranslationKeys = { submitBatch: "円形クロップ ({count} ファイル)", progressLabel: "円形にクロップ中", }, + "rounded-crop": { + submit: "角丸クロップ", + submitBatch: "角丸クロップ ({count} ファイル)", + progressLabel: "角丸にクロップ中", + }, duotone: { shadow: "シャドウカラー", highlight: "ハイライトカラー", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 40b43191..dee95446 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -700,6 +700,10 @@ export const ko: TranslationKeys = { name: "원형 크롭", description: "이미지를 투명 모서리의 원형으로 크롭", }, + "rounded-crop": { + name: "둥근 모서리 크롭", + description: "이미지를 투명 모서리의 둥근 사각형 또는 squircle로 크롭", + }, duotone: { name: "듀오톤", description: "사용자 지정 그림자 및 하이라이트 색상으로 듀오톤 효과 적용", @@ -2074,6 +2078,11 @@ export const ko: TranslationKeys = { submitBatch: "원형 크롭 ({count}개 파일)", progressLabel: "원형으로 크롭 중", }, + "rounded-crop": { + submit: "둥근 모서리 크롭", + submitBatch: "둥근 모서리 크롭 ({count}개 파일)", + progressLabel: "둥근 모양으로 크롭 중", + }, duotone: { shadow: "그림자 색상", highlight: "하이라이트 색상", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 5180e1a8..bf664215 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -749,6 +749,11 @@ export const nl: TranslationKeys = { name: "Cirkel bijsnijden", description: "Snijd afbeelding bij tot een gecentreerde cirkel met transparante hoeken", }, + "rounded-crop": { + name: "Afgerond bijsnijden", + description: + "Snijd afbeelding bij tot een afgerond vierkant of squircle met transparante hoeken", + }, duotone: { name: "Duotoon", description: "Pas een tweekleurig duotoon-effect toe met eigen schaduw- en lichtkleur", @@ -2141,6 +2146,11 @@ export const nl: TranslationKeys = { submitBatch: "Cirkel bijsnijden ({count} bestanden)", progressLabel: "Bijsnijden tot cirkel", }, + "rounded-crop": { + submit: "Afgerond bijsnijden", + submitBatch: "Afgerond bijsnijden ({count} bestanden)", + progressLabel: "Bijsnijden tot afgeronde vorm", + }, duotone: { shadow: "Schaduwkleur", highlight: "Lichtkleur", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index b0a14780..3d0c2d2b 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -748,6 +748,10 @@ export const pl: TranslationKeys = { name: "Kadrowanie do koła", description: "Przytnij obraz do koła z przezroczystymi rogami", }, + "rounded-crop": { + name: "Kadrowanie zaokrąglone", + description: "Przytnij obraz do zaokrąglonego kwadratu lub squircle z przezroczystymi rogami", + }, duotone: { name: "Duotone", description: "Zastosuj dwukolorowy efekt duotone z wybranymi kolorami cieni i świateł", @@ -2139,6 +2143,11 @@ export const pl: TranslationKeys = { submitBatch: "Przytnij do koła ({count} plików)", progressLabel: "Przycinanie do koła", }, + "rounded-crop": { + submit: "Przytnij zaokrąglone", + submitBatch: "Przytnij zaokrąglone ({count} plików)", + progressLabel: "Przycinanie do zaokrąglonego kształtu", + }, duotone: { shadow: "Kolor cieni", highlight: "Kolor świateł", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 458630f9..c569f1c3 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -747,6 +747,11 @@ export const ptBR: TranslationKeys = { name: "Recorte Circular", description: "Recorte a imagem em um círculo centralizado com cantos transparentes", }, + "rounded-crop": { + name: "Recorte Arredondado", + description: + "Recorte a imagem em um quadrado arredondado ou squircle com cantos transparentes", + }, duotone: { name: "Duotone", description: "Aplique um efeito duotone com cores personalizadas de sombra e destaque", @@ -2137,6 +2142,11 @@ export const ptBR: TranslationKeys = { submitBatch: "Recorte circular ({count} arquivos)", progressLabel: "Recortando em círculo", }, + "rounded-crop": { + submit: "Recorte arredondado", + submitBatch: "Recorte arredondado ({count} arquivos)", + progressLabel: "Recortando em forma arredondada", + }, duotone: { shadow: "Cor da sombra", highlight: "Cor do realce", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index aa3db587..696f4e53 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -751,6 +751,10 @@ export const ru: TranslationKeys = { name: "Круговая обрезка", description: "Обрезка изображения по центральному кругу с прозрачными углами", }, + "rounded-crop": { + name: "Обрезка со скруглением", + description: "Обрезка изображения до скруглённого квадрата или squircle с прозрачными углами", + }, duotone: { name: "Дуотон", description: "Двухцветный эффект дуотон с настраиваемыми цветами теней и бликов", @@ -2137,6 +2141,11 @@ export const ru: TranslationKeys = { submitBatch: "Круглая обрезка ({count} файлов)", progressLabel: "Обрезка по кругу", }, + "rounded-crop": { + submit: "Обрезка со скруглением", + submitBatch: "Обрезка со скруглением ({count} файлов)", + progressLabel: "Обрезка до скруглённой формы", + }, duotone: { shadow: "Цвет теней", highlight: "Цвет светов", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index a9c0cfd7..c915c155 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -747,6 +747,10 @@ export const sv: TranslationKeys = { name: "Cirkelbeskärning", description: "Beskär bilden till en centrerad cirkel med transparenta hörn", }, + "rounded-crop": { + name: "Rundad beskärning", + description: "Beskär bilden till en rundad kvadrat eller squircle med transparenta hörn", + }, duotone: { name: "Duoton", description: "Applicera en tvåfärgseffekt med anpassade skugg- och högdagerfärger", @@ -2134,6 +2138,11 @@ export const sv: TranslationKeys = { submitBatch: "Cirkulär beskärning ({count} filer)", progressLabel: "Beskär till cirkel", }, + "rounded-crop": { + submit: "Rundad beskärning", + submitBatch: "Rundad beskärning ({count} filer)", + progressLabel: "Beskär till rundad form", + }, duotone: { shadow: "Skuggfärg", highlight: "Högdagerfärg", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 67c236c4..18aee3e6 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -740,6 +740,10 @@ export const th: TranslationKeys = { name: "ครอปวงกลม", description: "ครอปรูปภาพเป็นวงกลมกึ่งกลางพร้อมมุมโปร่งใส", }, + "rounded-crop": { + name: "ครอปมุมมน", + description: "ครอปรูปภาพเป็นสี่เหลี่ยมมุมมนหรือ squircle พร้อมมุมโปร่งใส", + }, duotone: { name: "ดูโอโทน", description: "ใส่เอฟเฟกต์ดูโอโทนสองสีพร้อมกำหนดสีเงาและสีไฮไลต์เอง", @@ -2105,6 +2109,11 @@ export const th: TranslationKeys = { submitBatch: "ครอปวงกลม ({count} ไฟล์)", progressLabel: "กำลังครอปเป็นวงกลม", }, + "rounded-crop": { + submit: "ครอปมุมมน", + submitBatch: "ครอปมุมมน ({count} ไฟล์)", + progressLabel: "กำลังครอปเป็นรูปทรงมุมมน", + }, duotone: { shadow: "สีเงา", highlight: "สีไฮไลต์", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 1f664df0..9be4ae31 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -748,6 +748,10 @@ export const tr: TranslationKeys = { name: "Daire Kırpma", description: "Görseli saydam köşelerle ortalanmış daireye kırpın", }, + "rounded-crop": { + name: "Yuvarlatılmış Kırpma", + description: "Görseli saydam köşelerle yuvarlatılmış kareye veya squircle biçimine kırpın", + }, duotone: { name: "Çift Ton", description: "Özel gölge ve aydınlık renkleriyle çift tonlu efekt uygula", @@ -2139,6 +2143,11 @@ export const tr: TranslationKeys = { submitBatch: "Daire Kırpma ({count} dosya)", progressLabel: "Daire olarak kırpılıyor", }, + "rounded-crop": { + submit: "Yuvarlatılmış Kırpma", + submitBatch: "Yuvarlatılmış Kırpma ({count} dosya)", + progressLabel: "Yuvarlatılmış şekle kırpılıyor", + }, duotone: { shadow: "Gölge Rengi", highlight: "Aydınlık Rengi", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 729ab1d9..308c3b9b 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -748,6 +748,10 @@ export const uk: TranslationKeys = { name: "Кругове обрізання", description: "Обрізати зображення до кола по центру з прозорими кутами", }, + "rounded-crop": { + name: "Заокруглене обрізання", + description: "Обрізати зображення до заокругленого квадрата або squircle з прозорими кутами", + }, duotone: { name: "Дуотон", description: "Двоколірний ефект дуотону з налаштуванням кольорів тіней та світла", @@ -2137,6 +2141,11 @@ export const uk: TranslationKeys = { submitBatch: "Кругове обрізання ({count} файлів)", progressLabel: "Обрізання по колу", }, + "rounded-crop": { + submit: "Заокруглене обрізання", + submitBatch: "Заокруглене обрізання ({count} файлів)", + progressLabel: "Обрізання до заокругленої форми", + }, duotone: { shadow: "Колір тіні", highlight: "Колір світла", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 653b2231..b8f3a098 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -750,6 +750,10 @@ export const vi: TranslationKeys = { name: "Cắt hình tròn", description: "Cắt hình ảnh thành hình tròn ở giữa với các góc trong suốt", }, + "rounded-crop": { + name: "Cắt bo tròn", + description: "Cắt hình ảnh thành hình vuông bo tròn hoặc squircle với các góc trong suốt", + }, duotone: { name: "Hai tông màu", description: "Áp dụng hiệu ứng hai tông màu với màu bóng và màu sáng tùy chỉnh", @@ -2135,6 +2139,11 @@ export const vi: TranslationKeys = { submitBatch: "Cắt tròn ({count} tệp)", progressLabel: "Đang cắt tròn", }, + "rounded-crop": { + submit: "Cắt bo tròn", + submitBatch: "Cắt bo tròn ({count} tệp)", + progressLabel: "Đang cắt thành hình bo tròn", + }, duotone: { shadow: "Màu tối", highlight: "Màu sáng", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 32a80246..5f1156c6 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -531,6 +531,10 @@ export const zhCN: TranslationKeys = { name: "圆形裁剪", description: "将图片裁剪为居中圆形,四角透明", }, + "rounded-crop": { + name: "圆角裁剪", + description: "将图片裁剪为圆角方形或 squircle,四角透明", + }, duotone: { name: "双色调", description: "应用双色调效果,自定义阴影和高光颜色", @@ -1893,6 +1897,11 @@ export const zhCN: TranslationKeys = { submitBatch: "圆形裁剪({count} 个文件)", progressLabel: "正在圆形裁剪", }, + "rounded-crop": { + submit: "圆角裁剪", + submitBatch: "圆角裁剪({count} 个文件)", + progressLabel: "正在裁剪为圆角形状", + }, duotone: { shadow: "阴影颜色", highlight: "高光颜色", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 2a99df8f..c425c972 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -531,6 +531,10 @@ export const zhTW: TranslationKeys = { name: "圓形裁切", description: "將圖片裁切為置中圓形,四角透明", }, + "rounded-crop": { + name: "圓角裁切", + description: "將圖片裁切為圓角方形或 squircle,四角透明", + }, duotone: { name: "雙色調", description: "套用自訂暗部與亮部色彩的雙色調效果", @@ -1893,6 +1897,11 @@ export const zhTW: TranslationKeys = { submitBatch: "圓形裁切({count} 個檔案)", progressLabel: "正在圓形裁切", }, + "rounded-crop": { + submit: "圓角裁切", + submitBatch: "圓角裁切({count} 個檔案)", + progressLabel: "正在裁切為圓角形狀", + }, duotone: { shadow: "陰影顏色", highlight: "亮部顏色", diff --git a/tests/integration/platform/docs.test.ts b/tests/integration/platform/docs.test.ts index da401e89..7d0d14bb 100644 --- a/tests/integration/platform/docs.test.ts +++ b/tests/integration/platform/docs.test.ts @@ -120,13 +120,13 @@ describe("API docs", () => { const deployment = readFileSync(join(root, "apps/docs/guide/deployment.md"), "utf8"); const architecture = readFileSync(join(root, "apps/docs/guide/architecture.md"), "utf8"); - expect(gettingStarted).toContain("| **Image** | 106 |"); + expect(gettingStarted).toContain("| **Image** | 107 |"); expect(gettingStarted).toContain("| **Video** | 57 |"); expect(gettingStarted).toContain("| **Audio** | 27 |"); expect(gettingStarted).toContain("| **PDF / Document** | 42 |"); expect(gettingStarted).toContain("| **Files** | 10 |"); expect(deployment).not.toContain("All 138 non-AI tools"); - expect(architecture).toContain("242 tool routes"); + expect(architecture).toContain("243 tool routes"); }); it("serves an LLM summary with live catalog tools", async () => { @@ -136,7 +136,7 @@ describe("API docs", () => { }); expect(res.statusCode).toBe(200); expect(res.body).toContain("## Tools"); - expect(res.body).toContain("- Image (106 tools)"); + expect(res.body).toContain("- Image (107 tools)"); expect(res.body).toContain("Resize Image - Resize by pixels"); expect(res.body).toContain("Sign PDF -"); }); diff --git a/tests/integration/tools/image/rounded-crop.test.ts b/tests/integration/tools/image/rounded-crop.test.ts new file mode 100644 index 00000000..66d2c6ba --- /dev/null +++ b/tests/integration/tools/image/rounded-crop.test.ts @@ -0,0 +1,155 @@ +/** + * Integration tests for the rounded-crop tool (/api/v1/tools/image/rounded-crop). + * + * Covers rounded-square and squircle masking, PNG output, corner alpha + * transparency, straight-edge opacity (which proves it is a rounded square and + * not a circle), the sharp-corner (radius 0) case, and border/background/size. + */ + +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { fixtures, readFixture } from "../../../fixtures/index.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../../test-server.js"; + +const PNG = readFixture(fixtures.image.base.png200); // 200x150 + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +async function run(settings: Record, file = PNG, filename = "test.png") { + const contentType = filename.endsWith(".jpg") ? "image/jpeg" : "image/png"; + const { body, contentType: ct } = createMultipartPayload([ + { name: "file", filename, contentType, content: file }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return app.inject({ + method: "POST", + url: "/api/v1/tools/image/rounded-crop", + headers: { authorization: `Bearer ${adminToken}`, "content-type": ct }, + body, + }); +} + +async function download(url: string) { + const dlRes = await app.inject({ + method: "GET", + url, + headers: { authorization: `Bearer ${adminToken}` }, + }); + return dlRes.rawPayload; +} + +/** Alpha (0..255) of the pixel at (x, y) in a PNG buffer. */ +async function alphaAt(png: Buffer, x: number, y: number): Promise { + const { data, info } = await sharp(png).raw().toBuffer({ resolveWithObject: true }); + return data[(y * info.width + x) * info.channels + 3]; +} + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Rounded Crop", () => { + it("produces a rounded-square crop as a square PNG", async () => { + const res = await run({}); + expect(res.statusCode).toBe(200); + const png = await download(JSON.parse(res.body).downloadUrl); + const meta = await sharp(png).metadata(); + expect(meta.format).toBe("png"); + // min(200, 150) = 150 + expect(meta.width).toBe(150); + expect(meta.height).toBe(150); + }); + + it("rounds the corners: a corner pixel is transparent", async () => { + const res = await run({}); + const png = await download(JSON.parse(res.body).downloadUrl); + expect(await alphaAt(png, 1, 1)).toBe(0); + }); + + it("keeps the center opaque", async () => { + const res = await run({}); + const png = await download(JSON.parse(res.body).downloadUrl); + const meta = await sharp(png).metadata(); + const cx = Math.floor((meta.width ?? 0) / 2); + const cy = Math.floor((meta.height ?? 0) / 2); + expect(await alphaAt(png, cx, cy)).toBe(255); + }); + + it("keeps straight edges opaque (rounded square, not a circle)", async () => { + // Mid-top edge is on the straight run of a rounded square, so it stays + // opaque. On a circle that same pixel would be clipped away. + const res = await run({}); + const png = await download(JSON.parse(res.body).downloadUrl); + const meta = await sharp(png).metadata(); + const midX = Math.floor((meta.width ?? 0) / 2); + expect(await alphaAt(png, midX, 1)).toBe(255); + }); + + it("radius 0 gives a plain square with an opaque corner", async () => { + const res = await run({ cornerRadius: 0 }); + expect(res.statusCode).toBe(200); + const png = await download(JSON.parse(res.body).downloadUrl); + expect(await alphaAt(png, 1, 1)).toBe(255); + }); + + it("squircle shape: transparent corner, opaque center", async () => { + const res = await run({ shape: "squircle" }); + expect(res.statusCode).toBe(200); + const png = await download(JSON.parse(res.body).downloadUrl); + const meta = await sharp(png).metadata(); + const cx = Math.floor((meta.width ?? 0) / 2); + const cy = Math.floor((meta.height ?? 0) / 2); + expect(await alphaAt(png, 1, 1)).toBe(0); + expect(await alphaAt(png, cx, cy)).toBe(255); + }); + + it("always outputs 4-channel PNG regardless of input format", async () => { + const JPG = readFixture(fixtures.image.base.jpg100); + const res = await run({}, JPG, "test.jpg"); + expect(res.statusCode).toBe(200); + const png = await download(JSON.parse(res.body).downloadUrl); + const meta = await sharp(png).metadata(); + expect(meta.format).toBe("png"); + expect(meta.channels).toBe(4); + }); + + it("applies output size, border, and a solid background", async () => { + const res = await run({ + zoom: 2, + borderWidth: 10, + borderColor: "#ff0000", + background: "#0000ff", + outputSize: 128, + }); + expect(res.statusCode).toBe(200); + const png = await download(JSON.parse(res.body).downloadUrl); + const meta = await sharp(png).metadata(); + expect(meta.width).toBe(128); + expect(meta.height).toBe(128); + // Solid background fills the corners, so the corner is opaque. + expect(await alphaAt(png, 1, 1)).toBe(255); + }); + + it("rejects an unknown shape", async () => { + const res = await run({ shape: "circle" }); + expect(res.statusCode).toBe(400); + }); + + it("rejects a corner radius above the allowed range", async () => { + const res = await run({ cornerRadius: 80 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/unit/shared/section.test.ts b/tests/unit/shared/section.test.ts index a5fa2876..1b12e1a8 100644 --- a/tests/unit/shared/section.test.ts +++ b/tests/unit/shared/section.test.ts @@ -19,7 +19,7 @@ describe("toolSection", () => { TOOLS.filter((t) => toolSection(t) === s) .map((t) => t.id) .sort(); - expect(TOOLS.filter((t) => toolSection(t) === "image")).toHaveLength(106); + expect(TOOLS.filter((t) => toolSection(t) === "image")).toHaveLength(107); expect(TOOLS.filter((t) => toolSection(t) === "video")).toHaveLength(57); expect(TOOLS.filter((t) => toolSection(t) === "audio")).toHaveLength(27); expect(bySection("pdf")).toHaveLength(29);