#!/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 { 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 = ` ${ backgroundColor ? `` : "" } ${ escapeXml(text) } `; // 将 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, "'"); } 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' "); 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); } }