feat(beautify): add shadow rendering (extracted from border.ts pattern)

This commit is contained in:
SnapOtter
2026-05-08 16:06:37 +08:00
parent b13df56273
commit 39358524cd
2 changed files with 169 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
import sharp from "sharp";
import { parseHex } from "./constants.js";
interface ShadowOpts {
blur: number;
offsetX: number;
offsetY: number;
color: string;
opacity: number;
}
interface ShadowResult {
buffer: Buffer;
imgX: number;
imgY: number;
padLeft: number;
padTop: number;
}
export async function applyShadow(imageBuffer: Buffer, opts: ShadowOpts): Promise<ShadowResult> {
const buf = await sharp(imageBuffer).ensureAlpha().png().toBuffer();
const meta = await sharp(buf).metadata();
const bW = meta.width ?? 100;
const bH = meta.height ?? 100;
const sc = parseHex(opts.color);
const alpha = opts.opacity / 100;
const blur = opts.blur;
const spread = Math.ceil(blur * 2);
const ox = opts.offsetX;
const oy = opts.offsetY;
const shadowSilhouette = await sharp({
create: {
width: bW,
height: bH,
channels: 4,
background: { r: sc.r, g: sc.g, b: sc.b, alpha },
},
})
.composite([{ input: buf, blend: "dest-in" }])
.extend({
top: spread,
bottom: spread,
left: spread,
right: spread,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.blur(Math.max(blur, 0.3))
.png()
.toBuffer();
const padL = Math.max(0, spread - ox);
const padR = Math.max(0, spread + ox);
const padT = Math.max(0, spread - oy);
const padB = Math.max(0, spread + oy);
const canvasW = bW + padL + padR;
const canvasH = bH + padT + padB;
const imgX = padL;
const imgY = padT;
const shadX = Math.max(0, imgX + ox - spread);
const shadY = Math.max(0, imgY + oy - spread);
const result = await sharp({
create: {
width: canvasW,
height: canvasH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{ input: shadowSilhouette, left: shadX, top: shadY },
{ input: buf, left: imgX, top: imgY },
])
.png()
.toBuffer();
return { buffer: result, imgX, imgY, padLeft: padL, padTop: padT };
}
+87
View File
@@ -0,0 +1,87 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { applyShadow } from "../../apps/api/src/lib/beautify/shadow.js";
describe("applyShadow", () => {
const makeImage = (w: number, h: number) =>
sharp({
create: {
width: w,
height: h,
channels: 4,
background: { r: 100, g: 100, b: 255, alpha: 1 },
},
})
.png()
.toBuffer();
it("returns larger buffer with shadow applied", async () => {
const img = await makeImage(200, 150);
const result = await applyShadow(img, {
blur: 20,
offsetX: 0,
offsetY: 10,
color: "#000000",
opacity: 30,
});
const meta = await sharp(result.buffer).metadata();
expect(meta.width).toBeGreaterThan(200);
expect(meta.height).toBeGreaterThan(150);
expect(result.padLeft).toBeGreaterThanOrEqual(0);
expect(result.padTop).toBeGreaterThanOrEqual(0);
});
it("returns image position within shadow canvas", async () => {
const img = await makeImage(200, 150);
const result = await applyShadow(img, {
blur: 20,
offsetX: 5,
offsetY: 10,
color: "#000000",
opacity: 50,
});
expect(result.imgX).toBeDefined();
expect(result.imgY).toBeDefined();
});
it("handles zero-blur shadow gracefully", async () => {
const img = await makeImage(100, 100);
const result = await applyShadow(img, {
blur: 0,
offsetX: 0,
offsetY: 0,
color: "#000000",
opacity: 0,
});
const meta = await sharp(result.buffer).metadata();
expect(meta.width).toBeGreaterThanOrEqual(100);
});
it("handles negative offsets", async () => {
const img = await makeImage(200, 150);
const result = await applyShadow(img, {
blur: 15,
offsetX: -10,
offsetY: -5,
color: "#ff0000",
opacity: 40,
});
const meta = await sharp(result.buffer).metadata();
expect(meta.width).toBeGreaterThan(200);
expect(meta.height).toBeGreaterThan(150);
});
it("produces RGBA output with alpha channel", async () => {
const img = await makeImage(100, 100);
const result = await applyShadow(img, {
blur: 10,
offsetX: 0,
offsetY: 5,
color: "#000000",
opacity: 30,
});
const meta = await sharp(result.buffer).metadata();
expect(meta.hasAlpha).toBe(true);
expect(meta.channels).toBe(4);
});
});