mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user