157 lines
4.3 KiB
TypeScript
Executable File
157 lines
4.3 KiB
TypeScript
Executable File
#!/usr/bin/env -S deno run -A
|
|
|
|
import sharp from "sharp";
|
|
import {Buffer} from "node:buffer";
|
|
import {parseArgs} from "@std/cli/parse-args";
|
|
|
|
export interface WatermarkOptions {
|
|
/** 水印文字 */
|
|
text: string;
|
|
/** 字体大小 (默认 24) */
|
|
fontSize?: number;
|
|
/** 字体颜色 (默认白色,格式:#FFFFFF 或 rgba) */
|
|
color?: string;
|
|
/** 背景颜色 (默认透明,格式:#00000080 或 rgba) */
|
|
backgroundColor?: string;
|
|
/** 背景内边距 (默认 5) */
|
|
backgroundPadding?: number;
|
|
/** 距离右边距的像素 (默认 10) */
|
|
marginRight?: number;
|
|
/** 距离底边距的像素 (默认 10) */
|
|
marginBottom?: number;
|
|
/** 输出路径 (默认在原文件名后添加 _watermarked) */
|
|
outputPath?: string;
|
|
}
|
|
|
|
function getDefaultOutputPath(inputPath: string): string {
|
|
const lastDot = inputPath.lastIndexOf(".");
|
|
return lastDot !== -1
|
|
? `${inputPath.slice(0, lastDot)}_watermarked${inputPath.slice(lastDot)}`
|
|
: `${inputPath}_watermarked`;
|
|
}
|
|
|
|
/**
|
|
* 在图片右下角添加文字水印
|
|
* @param inputPath 输入图片路径
|
|
* @param options 水印选项
|
|
*/
|
|
export async function addWatermark(
|
|
inputPath: string,
|
|
options: WatermarkOptions,
|
|
): Promise<void> {
|
|
const {
|
|
text,
|
|
fontSize = 24,
|
|
color = "black",
|
|
backgroundColor,
|
|
backgroundPadding = 5,
|
|
marginRight = 10,
|
|
marginBottom = 10,
|
|
} = options;
|
|
|
|
let outputPath = options.outputPath;
|
|
if (!outputPath) {
|
|
outputPath = getDefaultOutputPath(inputPath);
|
|
}
|
|
|
|
// 获取原图信息
|
|
const metadata = await sharp(inputPath).metadata();
|
|
const width = metadata.width!;
|
|
const height = metadata.height!;
|
|
|
|
// 估算文字宽度(使用更精确的系数,根据常见字体调整)
|
|
// sans-serif 字体在 SVG 中的平均宽度系数约为 0.5-0.6
|
|
const textWidth = Math.ceil(text.length * fontSize * 0.55);
|
|
const textHeight = Math.ceil(fontSize * 0.9);
|
|
|
|
// 背景矩形尺寸
|
|
const bgWidth = textWidth + backgroundPadding * 2;
|
|
const bgHeight = textHeight + backgroundPadding * 2;
|
|
|
|
// 文字和背景位置(右下角对齐)
|
|
// text-anchor="end" 意味着文字的右边缘在指定的 x 坐标
|
|
const textX = width - marginRight - 20;
|
|
const textY = height - marginBottom;
|
|
|
|
// 背景矩形的左上角坐标
|
|
// 背景右边缘与文字右边缘对齐
|
|
const rectX = textX - bgWidth + 20;
|
|
const rectY = textY - bgHeight + backgroundPadding + 2;
|
|
|
|
// 创建最终 SVG 水印
|
|
const svg =
|
|
`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
${
|
|
backgroundColor
|
|
? `<rect x="${rectX}" y="${rectY}" width="${bgWidth}" height="${bgHeight}" fill="${backgroundColor}" rx="4"/>`
|
|
: ""
|
|
}
|
|
<text x="${textX}" y="${textY}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" text-anchor="end" dominant-baseline="alphabetic">${
|
|
escapeXml(text)
|
|
}</text>
|
|
</svg>`;
|
|
|
|
// 将 SVG 水印叠加到原图
|
|
await sharp(inputPath).composite([
|
|
{
|
|
input: Buffer.from(svg),
|
|
top: 0,
|
|
left: 0,
|
|
},
|
|
])
|
|
.toFile(outputPath);
|
|
|
|
if (inputPath == outputPath) {
|
|
console.log(`Added watermark: ${inputPath}`);
|
|
} else {
|
|
console.log(`Added watermark: ${inputPath} -> ${outputPath}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 转义 XML 特殊字符
|
|
*/
|
|
function escapeXml(str: string): string {
|
|
return str
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const flags = parseArgs(Deno.args, {
|
|
boolean: ["help", "unsafe-replace-file"],
|
|
string: ["watermark"],
|
|
});
|
|
|
|
const watermarkText = flags.watermark;
|
|
const imagePaths = flags._;
|
|
|
|
if (!watermarkText || imagePaths.length == 0) {
|
|
console.log("Usage: deno run -A main.ts --watermark 'watermark' <FILES>");
|
|
console.log(
|
|
"Example: deno run -A main.ts --watermark '© 2026 MyName' image.jpg",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
for (const imagePath of imagePaths) {
|
|
const imagePathStr = imagePath as string;
|
|
|
|
const options = {
|
|
fontSize: 32,
|
|
text: watermarkText,
|
|
color: "black",
|
|
backgroundPadding: 2,
|
|
backgroundColor: "white",
|
|
} as WatermarkOptions;
|
|
if (flags["unsafe-replace-file"]) {
|
|
options.outputPath = imagePathStr;
|
|
}
|
|
|
|
await addWatermark(imagePathStr, options);
|
|
}
|
|
}
|