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
This commit is contained in:
SnapOtter
2026-07-21 16:48:48 +08:00
committed by GitHub
parent 7d37f6e6f5
commit e7ffb37e98
35 changed files with 935 additions and 6 deletions
+53
View File
@@ -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
+2
View File
@@ -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<void> {
{ 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 },
+137
View File
@@ -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<typeof settingsSchema>["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"
? `<path d="${squirclePath(size)}" fill="${fill}"/>`
: `<rect width="${size}" height="${size}" rx="${radiusPx}" ry="${radiusPx}" fill="${fill}"/>`;
return Buffer.from(`<svg width="${size}" height="${size}">${body}</svg>`);
}
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" };
},
});
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 |
+2
View File
@@ -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
@@ -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<Shape>("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<string, unknown> = {
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 (
<div className="space-y-4">
{/* Live framing preview */}
{hasFile && blobUrl && (
<div className="flex justify-center">
<div
className="relative"
style={{
width: PREVIEW_D + bwPx * 2,
height: PREVIEW_D + bwPx * 2,
padding: bwPx,
background: bwPx > 0 ? borderColor : "transparent",
...outerShape,
}}
>
<div
className="relative overflow-hidden touch-none cursor-grab active:cursor-grabbing"
style={{
width: PREVIEW_D,
height: PREVIEW_D,
background: bgMode === "color" ? bgColor : "transparent",
...innerShape,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{/* checkerboard hint for transparency */}
{bgMode === "transparent" && (
<div
className="absolute inset-0"
style={{
backgroundImage:
"linear-gradient(45deg,#0001 25%,transparent 25%),linear-gradient(-45deg,#0001 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#0001 75%),linear-gradient(-45deg,transparent 75%,#0001 75%)",
backgroundSize: "16px 16px",
backgroundPosition: "0 0,0 8px,8px -8px,-8px 0",
}}
/>
)}
<img
src={blobUrl}
alt=""
draggable={false}
className="absolute max-w-none select-none"
style={{
width: W * scale,
height: H * scale,
left: -left * scale,
top: -top * scale,
}}
/>
</div>
</div>
</div>
)}
{hasFile && (
<p className="text-center text-[10px] text-muted-foreground">
Drag the preview to reposition
</p>
)}
{/* Shape */}
<div>
<span className="text-xs text-muted-foreground">Shape</span>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setShape("rounded-square")}
className={`flex-1 text-xs py-1.5 rounded ${shape === "rounded-square" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Rounded square
</button>
<button
type="button"
onClick={() => setShape("squircle")}
className={`flex-1 text-xs py-1.5 rounded ${shape === "squircle" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Squircle
</button>
</div>
</div>
{/* Corner radius (rounded square only) */}
{shape === "rounded-square" && (
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Corner radius</span>
<span className="text-xs font-mono text-foreground">{cornerRadius}%</span>
</div>
<input
type="range"
min={0}
max={50}
value={cornerRadius}
onChange={(e) => setCornerRadius(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
)}
{/* Zoom */}
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Zoom</span>
<span className="text-xs font-mono text-foreground">{zoom.toFixed(1)}x</span>
</div>
<input
type="range"
min={1}
max={5}
step={0.1}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Border */}
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Border</span>
<span className="text-xs font-mono text-foreground">{borderWidth}px</span>
</div>
<div className="flex items-center gap-2 mt-1">
<input
type="range"
min={0}
max={100}
value={borderWidth}
onChange={(e) => setBorderWidth(Number(e.target.value))}
className="flex-1 min-w-0"
/>
<input
type="color"
value={borderColor}
onChange={(e) => setBorderColor(e.target.value)}
aria-label="Border color"
className="h-7 w-9 shrink-0 rounded border border-border bg-background"
/>
</div>
</div>
{/* Background */}
<div>
<span className="text-xs text-muted-foreground">Background</span>
<div className="flex items-center gap-2 mt-1">
<div className="flex flex-1 gap-1">
<button
type="button"
onClick={() => setBgMode("transparent")}
className={`flex-1 text-xs py-1.5 rounded ${bgMode === "transparent" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Transparent
</button>
<button
type="button"
onClick={() => setBgMode("color")}
className={`flex-1 text-xs py-1.5 rounded ${bgMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Color
</button>
</div>
{bgMode === "color" && (
<input
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
aria-label="Background color"
className="h-7 w-9 shrink-0 rounded border border-border bg-background"
/>
)}
</div>
</div>
{/* Output size */}
<div>
<label htmlFor="rc-output-size" className="text-xs text-muted-foreground">
Output size (px)
</label>
<input
id="rc-output-size"
type="number"
min={16}
value={outputSize}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-xs text-destructive-ink">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings["rounded-crop"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="rounded-crop-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{files.length > 1
? t.toolSettings["rounded-crop"].submitBatch.replace("{count}", String(files.length))
: t.toolSettings["rounded-crop"].submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="rounded-crop-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary-ink font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
{t.common.download}
</a>
)}
</div>
);
}
+2
View File
@@ -94,6 +94,7 @@ import {
SlidersHorizontal,
Sparkles,
Split,
Squircle,
Stamp,
Star,
Table,
@@ -208,6 +209,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
SlidersHorizontal,
Sparkles,
Split,
Squircle,
Stamp,
Star,
Table,
+1
View File
@@ -61,6 +61,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
border: "live-preview",
beautify: "live-preview",
"circle-crop": "live-preview",
"rounded-crop": "live-preview",
duotone: "live-preview",
"image-pad": "live-preview",
pixelate: "live-preview",
+6
View File
@@ -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 }],
+21
View File
@@ -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",
+9
View File
@@ -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: "لون الإبراز",
+10
View File
@@ -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",
+9
View File
@@ -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",
+10
View File
@@ -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",
+9
View File
@@ -750,6 +750,10 @@ export const fr: TranslationKeys = {
name: "Recadrage circulaire",
description: "Recadrez limage en cercle centré avec coins transparents",
},
"rounded-crop": {
name: "Recadrage arrondi",
description: "Recadrez limage 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",
+9
View File
@@ -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: "हाइलाइट रंग",
+9
View File
@@ -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",
+10
View File
@@ -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",
+9
View File
@@ -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: "ハイライトカラー",
+9
View File
@@ -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: "하이라이트 색상",
+10
View File
@@ -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",
+9
View File
@@ -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ł",
+10
View File
@@ -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",
+9
View File
@@ -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: "Цвет светов",
+9
View File
@@ -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",
+9
View File
@@ -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: "สีไฮไลต์",
+9
View File
@@ -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",
+9
View File
@@ -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: "Колір світла",
+9
View File
@@ -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",
+9
View File
@@ -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: "高光颜色",
+9
View File
@@ -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: "亮部顏色",
+3 -3
View File
@@ -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 -");
});
@@ -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<string, unknown>, 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<number> {
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);
});
});
+1 -1
View File
@@ -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);