给图片批量加水印:位置和透明度都能调
用 Sharp 给文件夹里的图片批量加文字水印,九宫格位置、透明度、字号都能调。默认只预览,确认后加 --write 输出到 out/,原图不动。
30 秒速查
- 图片放
input/,先确认脚本能跑- 预览:
node resources/watermark-images/watermark.mjs input- 加文字和透明度:
--text 跟我做 --opacity 0.5- 确认后加
--write,输出到input/out/
这篇教程解决什么问题
一批图要加上同一个水印:公众号配图、小红书封面、商品图。手动一张张打开、打字、调位置,二十张就要半小时,还容易有的偏左有的偏右。
这篇用一个脚本批量加文字水印:九宫格位置、透明度、字号都能调,默认只预览,确认后输出到 out/。
准备图片
新建 input/,放两张测试图(jpg/png/webp 都行)。第一次先用两三张,确认字体和位置,再跑整批。
参数
| 参数 | 默认 | 说明 |
|---|---|---|
--text |
跟我做 |
水印文字 |
--opacity |
0.35 |
0 到 1,越大越明显 |
--size |
32 |
字号,像素 |
--position |
southeast |
九宫格位置 |
位置参数直接传给 Sharp 的 gravity,所以用英文方向名。
写脚本
保存为 resources/watermark-images/watermark.mjs:
import sharp from "sharp";
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
// 给图片右下角加文字水印。默认只预览,加 --write 才输出到 out/。
// 中文能否显示取决于机器上有没有中文字体;Noto Sans CJK / 苹方 / 微软雅黑都行。
const POSITIONS = new Set([
"northwest",
"north",
"northeast",
"west",
"center",
"east",
"southwest",
"south",
"southeast",
]);
function escapeXml(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
}
export function buildSvg({ text = "跟我做", opacity = 0.35, size = 32 } = {}) {
const width = Math.max(120, Math.ceil([...text].length * size * 1.1) + 20);
const height = size * 2;
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<text x="${width / 2}" y="${size + 6}" text-anchor="middle"
font-family="'Noto Sans CJK SC','PingFang SC','Microsoft YaHei',sans-serif"
font-size="${size}"
fill="rgba(255,255,255,${opacity})"
stroke="rgba(0,0,0,${Math.min(0.6, opacity + 0.1)})"
stroke-width="1">${escapeXml(text)}</text>
</svg>`;
}
export async function collectImages(target) {
const info = await stat(target);
if (!info.isDirectory()) return [target];
const entries = await readdir(target, { withFileTypes: true });
return entries
.filter(
(entry) =>
entry.isFile() &&
/\.(jpe?g|png|webp)$/i.test(entry.name) &&
!entry.name.startsWith("."),
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => path.join(target, entry.name));
}
export async function watermarkFile(file, options = {}) {
const {
write = false,
outDir,
text = "跟我做",
opacity = 0.35,
size = 32,
position = "southeast",
} = options;
if (!POSITIONS.has(position)) {
throw new Error(`不支持的位置:${position}`);
}
const svg = buildSvg({ text, opacity, size });
const buffer = await sharp(file)
.composite([{ input: Buffer.from(svg), gravity: position }])
.jpeg({ quality: 90 })
.toBuffer();
const target = path.join(outDir, path.basename(file, path.extname(file)) + ".jpg");
if (write) {
await mkdir(outDir, { recursive: true });
await writeFile(target, buffer);
}
const metadata = await sharp(file).metadata();
return { file, target, width: metadata.width, height: metadata.height, written: write };
}
export async function watermarkAll(target, options = {}) {
const files = await collectImages(target);
const outDir = path.join(
path.extname(target) ? path.dirname(target) : target,
"out",
);
const results = [];
for (const file of files) {
results.push(await watermarkFile(file, { ...options, outDir }));
}
return results;
}
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const args = process.argv.slice(2);
const valueOf = (flag, fallback) => {
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : fallback;
};
const target = args.find((arg) => !arg.startsWith("--")) ?? "input";
const write = args.includes("--write");
const text = valueOf("--text", "跟我做");
const opacity = Number(valueOf("--opacity", "0.35"));
const size = Number(valueOf("--size", "32"));
const position = valueOf("--position", "southeast");
try {
const results = await watermarkAll(target, {
write,
text,
opacity,
size,
position,
});
if (results.length === 0) {
console.error("没有找到 jpg / png / webp 图片。");
process.exitCode = 1;
} else {
for (const item of results) {
const name = path.basename(item.file);
const flag = write ? "已加水印" : "预览";
console.log(`[${flag}] ${name} → ${path.basename(item.target)}(${position},${size}px)`);
}
console.log(`\n共 ${results.length} 张${write ? ",已输出到 out/" : ";加 --write 执行"}。`);
}
} catch (error) {
if (error.code === "ENOENT") {
console.error(`打不开路径:${target}`);
process.exitCode = 1;
} else {
throw error;
}
}
}
三个关键点:
- 文字先转成 SVG,再用 Sharp 合成,不需要浏览器;
- 白色半透明文字加一圈深色描边,深浅背景都能看清;
- 输出统一 JPEG,质量 90,原图不动。
先预览
node resources/watermark-images/watermark.mjs input
真实输出:
[预览] dark.jpg → dark.jpg(southeast,32px)
[预览] photo.jpg → photo.jpg(southeast,32px)
共 2 张;加 --write 执行。
写入
node resources/watermark-images/watermark.mjs input --write
[已加水印] dark.jpg → dark.jpg(southeast,32px)
[已加水印] photo.jpg → photo.jpg(southeast,32px)
共 2 张,已输出到 out/。

想更明显或换位置:
node resources/watermark-images/watermark.mjs input --write --text "公众号:跟我做" --opacity 0.6 --position northeast
水印放哪里
- 右下角最不挡主体,但平台裁图时可能被切;重要图留 60px 边距。
- 商品图放左上或居中,避开价格和标签。
- 一组图用同一个位置,换图不换位置,看起来才统一。
原图先备份一份。水印是叠加,不是替换;从原图重跑才不会越叠越厚。
常见坑
1. 中文变方框
水印是机器上的字体渲染的。没有中文字体就会出现方框。装 Noto Sans CJK 或苹方,或把 font-family 改成系统里已有的中文字体。
2. 水印太淡或太抢
0.35 适合深色照片;浅色背景调到 0.5–0.6,或者改描边颜色。发布前抽三张看,不要只信参数。
3. 反复加水印
每跑一次都会在上一次的结果上再加一层。每次从原图目录重新跑,不要拿 out/ 里的图再跑。
4. PNG 透明背景
统一输出 JPEG,透明区域会变黑。需要透明背景时,把输出改成 PNG,并去掉不必要的 flatten。
下一步读什么
水印加完,通常还要压体积:批量压缩图片 会把最长边缩到 1600px、质量 80,适合上传前处理。
常见问题
中文水印变成方框怎么办?
运行脚本的机器上没有中文字体。装 Noto Sans CJK、苹方或微软雅黑之一,或把脚本里的 font-family 改成系统已有字体。
会覆盖原图吗?
不会。只读输入目录,结果写到 input/out/;加 --write 才写盘,之前只打印每张图的处理结果。
能放在哪些位置?
支持九宫格:northwest、north、northeast、west、center、east、southwest、south、southeast。默认右下角。
PNG 的透明背景会怎样?
脚本统一输出 JPEG,透明区域会被填成黑色。需要透明背景时,把输出改成 PNG,并调整合成顺序。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。