跳到正文
跟我做
自媒体自动化3 分钟Sharp

给图片批量加水印:位置和透明度都能调

用 Sharp 给文件夹里的图片批量加文字水印,九宫格位置、透明度、字号都能调。默认只预览,确认后加 --write 输出到 out/,原图不动。

约 3 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 准备图片
  3. 参数
  4. 写脚本
  5. 先预览
  6. 写入
  7. 水印放哪里
  8. 常见坑
  9. 1. 中文变方框
  10. 2. 水印太淡或太抢
  11. 3. 反复加水印
  12. 4. PNG 透明背景
  13. 下一步读什么

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("<", "&lt;")
    .replaceAll(">", "&gt;");
}

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/。
深色图片右下角出现半透明的跟我做水印
右下角 · 「跟我做」· 32px · 透明度 0.35(默认值;图里调到 0.5 才看得清)

想更明显或换位置:

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,适合上传前处理。

两篇都属于自媒体自动化入门的图片处理环节,工具说明见 Sharp。

常见问题

中文水印变成方框怎么办?

运行脚本的机器上没有中文字体。装 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说「退订」就删掉。详见隐私政策。

同方向继续

全部教程