WebP reduces UI payload; JPEG keeps portrait saves compatible.\nFinish ends the visit without prompting another portrait.
119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
import sharp, { OverlayOptions } from "sharp";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
|
|
/**
|
|
* Pure JavaScript image watermarking via Sharp (no Python required).
|
|
* Applies:
|
|
* 1. School logo in top-left corner
|
|
* 2. WeChat-scannable QR code in bottom-right corner with white backing
|
|
* 3. Bottom typography banner: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
|
*/
|
|
export async function applyWatermarkToFile(filePath: string): Promise<void> {
|
|
if (!fs.existsSync(filePath)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const baseDir = process.cwd();
|
|
const logoPath = path.join(baseDir, "public", "assets", "brand", "shanghai_karate_logo.webp");
|
|
const qrPath = path.join(baseDir, "public", "assets", "brand", "dojo_qr.png");
|
|
|
|
const baseImage = sharp(filePath);
|
|
const metadata = await baseImage.metadata();
|
|
const bw = metadata.width || 1024;
|
|
const bh = metadata.height || 1024;
|
|
|
|
const margin = Math.round(bw * 0.035);
|
|
const compositeLayers: OverlayOptions[] = [];
|
|
|
|
// 1. School Logo (Top-Left)
|
|
if (fs.existsSync(logoPath)) {
|
|
const targetLogoW = Math.round(bw * 0.28);
|
|
const resizedLogoBuffer = await sharp(logoPath)
|
|
.resize({ width: targetLogoW })
|
|
.png()
|
|
.toBuffer();
|
|
|
|
compositeLayers.push({
|
|
input: resizedLogoBuffer,
|
|
left: margin,
|
|
top: margin,
|
|
});
|
|
}
|
|
|
|
// 2. Scannable WeChat QR Code (Bottom-Right)
|
|
if (fs.existsSync(qrPath)) {
|
|
const targetQrW = Math.round(bw * 0.17);
|
|
const qrPad = 6;
|
|
const totalQrW = targetQrW + qrPad * 2;
|
|
|
|
// Clean white backing with slight rounded corners for maximum readability
|
|
const whiteBackingSvg = Buffer.from(
|
|
`<svg width="${totalQrW}" height="${totalQrW}" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="100%" height="100%" rx="6" fill="#ffffff" fill-opacity="0.96"/>
|
|
</svg>`
|
|
);
|
|
|
|
const resizedQrBuffer = await sharp(qrPath)
|
|
.resize({ width: targetQrW, height: targetQrW })
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const qrWithBg = await sharp(whiteBackingSvg)
|
|
.composite([{ input: resizedQrBuffer, left: qrPad, top: qrPad }])
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const qrPosX = bw - totalQrW - margin;
|
|
const qrPosY = bh - totalQrW - Math.round(margin * 1.5);
|
|
|
|
compositeLayers.push({
|
|
input: qrWithBg,
|
|
left: qrPosX,
|
|
top: qrPosY,
|
|
});
|
|
}
|
|
|
|
// 3. Bottom Text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
|
const fontSize = Math.max(14, Math.round(bw * 0.026));
|
|
const textSvg = Buffer.from(
|
|
`<svg width="${bw}" height="${Math.round(fontSize * 2.5)}" xmlns="http://www.w3.org/2000/svg">
|
|
<style>
|
|
.text {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
font-size: ${fontSize}px;
|
|
font-weight: 700;
|
|
}
|
|
.shadow {
|
|
fill: rgba(0, 0, 0, 0.85);
|
|
}
|
|
.main {
|
|
fill: #fff0c8;
|
|
}
|
|
</style>
|
|
<text x="2" y="${fontSize + 2}" class="text shadow">上海健志空手道馆 · Shanghai Karate Club · Est. 2008</text>
|
|
<text x="0" y="${fontSize}" class="text main">上海健志空手道馆 · Shanghai Karate Club · Est. 2008</text>
|
|
</svg>`
|
|
);
|
|
|
|
const textPosY = bh - Math.round(margin * 1.2) - fontSize;
|
|
compositeLayers.push({
|
|
input: textSvg,
|
|
left: margin,
|
|
top: Math.max(0, textPosY),
|
|
});
|
|
|
|
// Apply all overlays and save to file
|
|
const outputBuffer = await baseImage
|
|
.composite(compositeLayers)
|
|
.jpeg({ quality: 95 })
|
|
.toBuffer();
|
|
|
|
fs.writeFileSync(filePath, outputBuffer);
|
|
console.log(`[Watermark] Pure JS watermark successfully applied to ${filePath}`);
|
|
} catch (err) {
|
|
console.warn("[Watermark] Failed to apply watermark in JavaScript:", err);
|
|
}
|
|
}
|