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

小红书批量封面:用 SVG 模板和脚本一次出 20 张

把封面文案写进 JSON,用 SVG 模板加 Sharp 批量渲染成 1080×1440 的封面图,不用一张张打开设计软件。

约 7 分钟
本页目录
  1. 适用场景
  2. 初始化项目
  3. 先说清楚字体
  4. 准备文案数据
  5. 写 SVG 模板
  6. 几个容易踩的坑
  7. 1. 文本注入要转义
  8. 2. 行高和字号要一起算
  9. 3. 输出格式
  10. 4. 批量生成后要抽查
  11. 想加背景图怎么办
  12. 什么时候该换方案
  13. 下一步读什么

30 秒速查

  • 文案写 data/covers.json:title、subtitle、badge
  • 画布默认 1080×1440,--size 9:16 / --size 1:1 换比例
  • 先确认字体存在,再跑 node scripts/render.mjs
  • 输出到 output/,先抽 3 张检查

适用场景

你需要连续发一批封面,标题不同、结构相同。手动做三张还行,二十张就会开始复制粘贴到出错。

这篇用一个最小方案解决它:文案放在 JSON 里,版式放在 SVG 里,脚本负责批量渲染。

左边是 covers.json 里的 title、subtitle、badge 三个字段,右边是脚本渲染出的第一张封面
① 文案只要几个字段 ② 跑完就是这个样子

初始化项目

mkdir cover-batch && cd cover-batch
npm init -y
npm i sharp
mkdir -p data output

Sharp 是 Node 里常用的图像处理库,安装时会下载对应平台的二进制,不需要额外配置。

先说清楚字体

SVG 里的中文能否正常显示,取决于运行脚本的机器上有没有对应字体。

  • macOS 通常自带苹方,font-family 写 PingFang SC。
  • Windows 可以写 Microsoft YaHei。
  • Linux 服务器建议先安装 Noto Sans CJK SC,否则可能渲染成方框。

一个稳妥的写法是把多个字体名串起来:

font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
终端显示 done: cover-01.jpg(1080×1440),右边封面上的中文全变成方框
少了中文字体就是这样:脚本不报错,图照样“生成成功”

准备文案数据

新建 data/covers.json。每条只需要标题、副标题和角标:

[
  {
    "title": "一条视频\n三个平台版本",
    "subtitle": "尺寸、封面、文案全部自动生成",
    "badge": "自动化 01"
  },
  {
    "title": "别再手动\n改视频尺寸了",
    "subtitle": "FFmpeg 批量处理,一次跑完",
    "badge": "自动化 02"
  },
  {
    "title": "20 张封面\n十分钟做完",
    "subtitle": "模板 + JSON + 一段脚本",
    "badge": "自动化 03"
  }
]

\n 用来手动换行。中文标题建议控制在每行 7 到 10 个字,三行以内。

写 SVG 模板

新建 scripts/render.mjs。完整脚本如下——模板写成函数,文字通过参数注入,最后再交给 Sharp 渲染成 JPG:

import sharp from "sharp";
import { readFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";

const ROOT = import.meta.dirname;
const DATA_FILE = path.join(ROOT, "data", "covers.json");
const OUTPUT_DIR = path.join(ROOT, "output");

// 三档成品比例:宽度都是 1080,只换高。
export const SIZES = {
  "3:4": { width: 1080, height: 1440 }, // 小红书
  "9:16": { width: 1080, height: 1920 }, // 抖音 / 快手
  "1:1": { width: 1080, height: 1080 }, // 闲鱼
};

export const DEFAULT_SIZE = "3:4";
export const WIDTH = SIZES[DEFAULT_SIZE].width;
export const HEIGHT = SIZES[DEFAULT_SIZE].height;

const FONT = "PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif";
const COLORS = {
  ink: "#14211d",
  muted: "#5f6b66",
  line: "#e5e2d8",
  paper: "#faf8f3",
  brand: "#009084",
  brandText: "#006a60",
  sand: "#f1ece1",
};

export function escapeXml(value) {
  return String(value ?? "")
    .replaceAll("&", "&")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&apos;");
}

// 粗略字宽:中日韩与全角按 1em,拉丁字母数字按 0.56em,其余按 0.5em。
// SVG 没有自动换行,封面文案固定,估宽够用。
function unitWidth(unit, fontSize) {
  if (/[\u3000-\u9fff\uff00-\uffef]/.test(unit)) return fontSize;
  // 拉丁是一次匹配成一个词(如 SQLite),要按字符数乘开,否则整段只算一个字宽。
  // 0.62em 是实测值(56px 粗体渲染后量出来的平均字宽)。
  if (/^[A-Za-z0-9]/.test(unit)) return unit.length * fontSize * 0.62;
  return fontSize * 0.55;
}

function tokenize(text) {
  return text.match(/[\u3000-\u9fff\uff00-\uffef]|[A-Za-z0-9][A-Za-z0-9.,:_+-]*|\s+|[^\s]/g) ?? [];
}

function measure(text, fontSize) {
  return tokenize(String(text ?? "")).reduce((sum, unit) => sum + unitWidth(unit, fontSize), 0);
}

/** 按宽度折行;文本里的 \n 视为强制换行,超过 maxLines 时截断并补省略号。 */
function wrapText(text, maxWidth, fontSize, maxLines) {
  const paragraphs = String(text ?? "").split("\n");

  if (paragraphs.length > 1) {
    const lines = [];
    for (const paragraph of paragraphs) {
      const room = maxLines - lines.length;
      if (room <= 0) break;
      lines.push(...wrapParagraph(paragraph, maxWidth, fontSize, room));
    }
    return lines.slice(0, maxLines);
  }

  return wrapParagraph(paragraphs[0], maxWidth, fontSize, maxLines);
}

function wrapParagraph(text, maxWidth, fontSize, maxLines) {
  const units = tokenize(String(text ?? "").trim());
  const lines = [];
  let line = "";
  let width = 0;
  let index = 0;

  while (index < units.length) {
    const unit = units[index];
    index += 1;

    if (/^\s+$/.test(unit)) {
      if (line) {
        line += " ";
        width += fontSize * 0.28;
      }
      continue;
    }

    const w = unitWidth(unit, fontSize);

    if (line && width + w > maxWidth) {
      lines.push(line.trimEnd());
      if (lines.length === maxLines) {
        const rest = (units.slice(index - 1).join("")).trim();
        if (rest) lines[lines.length - 1] = `${lines[lines.length - 1]}…`;
        return lines;
      }
      line = unit;
      width = w;
      continue;
    }

    line += unit;
    width += w;
  }

  if (line.trim()) lines.push(line.trimEnd());
  return lines.slice(0, maxLines);
}

function textLines(lines, { x, y, fontSize, lineHeight, weight, fill }) {
  return lines
    .map(
      (line, i) =>
        `<text x="${x}" y="${y + i * lineHeight}" font-size="${fontSize}" font-weight="${weight}" fill="${fill}" font-family="${FONT}">${escapeXml(line)}</text>`,
    )
    .join("");
}

function pill({ x, y, label, fontSize, height, stroke, fill, color, anchorRight }) {
  const padX = 16;
  const width = Math.round(measure(label, fontSize) + padX * 2);
  const left = anchorRight ? x - width : x;

  return `<g>
    <rect x="${left}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" fill="${fill}"${stroke ? ` stroke="${stroke}"` : ""} />
    <text x="${left + width / 2}" y="${y + height / 2 + fontSize * 0.36}" font-size="${fontSize}" font-weight="500" fill="${color}" text-anchor="middle" font-family="${FONT}">${escapeXml(label)}</text>
  </g>`;
}

/**
 * 生成一张封面 SVG。
 *
 * 版式(自上而下):青绿磁条(贴死顶部两角)→ 元信息行 → 标题 → 副标题 → 描述
 *                 → 底行(左辅助标签 + 右工具图标)。
 * 字段都可选,缺就不画:
 *   title       标题(56/700)
 *   subtitle    副标题(32/500,#006a60)
 *   description 描述(muted,3:4 与 9:16 最多 3 行,1:1 最多 2 行)
 *   info        描述下面的一行结构化信息,如「本机实测 · 0 元 · 4 分钟」
 *   meta        元信息,如「开发者 AI 实战 · 6 分钟 · 2026-10-26」
 *   mainLabel   右上角描边胶囊,如「Dev & AI」
 *   auxLabel    左下角浅底胶囊,如「SQLite」
 *   icon        工具图标的内层 SVG 字符串(按 32×32 画布缩放,站点用 tool-icons 喂)
 *   badge       兼容旧数据:没有 mainLabel 时显示在右上角
 */
export function buildSvg(entry = {}, { size = DEFAULT_SIZE, transparent = false } = {}) {
  const preset = SIZES[size];

  if (!preset) {
    throw new Error(`未知比例 ${size},可选:${Object.keys(SIZES).join(" / ")}`);
  }

  const { width, height } = preset;
  const pad = 64;
  const barHeight = 120;
  const contentWidth = width - pad * 2;
  const descMaxLines = size === "1:1" ? 2 : 3;
  const topGap = 96;
  const bottomGap = 48;

  // 每档的字号与对齐:3:4 与 9:16 内容靠上,1:1 维持居中。
  const TYPE_SCALE = {
    "3:4": { title: 84, subtitle: 48, desc: 32, align: "top", gap: 24 },
    "9:16": { title: 104, subtitle: 56, desc: 40, align: "top", gap: 28 },
    "1:1": { title: 56, subtitle: 32, desc: 24, align: "center", gap: 18 },
  };
  const scale = TYPE_SCALE[size];

  const metaSize = 14;
  const titleSize = scale.title;
  const titleLine = Math.round(titleSize * 1.1);
  const subtitleSize = scale.subtitle;
  const subtitleLine = Math.round(subtitleSize * 1.25);
  const descSize = scale.desc;
  const descLine = Math.round(descSize * 1.5);
  const infoSize = 16;
  const infoGap = 24;
  const iconSize = 56;

  const meta = entry.meta ?? "";
  const mainLabel = entry.mainLabel ?? entry.badge ?? "";
  const auxLabel = entry.auxLabel ?? "";
  const info = entry.info ?? "";
  const icon = typeof entry.icon === "string" && entry.icon.startsWith("<") ? entry.icon : "";

  const titleLines = wrapText(entry.title ?? "", contentWidth, titleSize, 3);
  const subtitleLines = wrapText(entry.subtitle ?? "", contentWidth, subtitleSize, 2);
  const descLines = wrapText(entry.description ?? "", contentWidth, descSize, descMaxLines);

  const gap = scale.gap;
  const metaRowHeight = meta || mainLabel ? 32 : 0;
  const blockHeight =
    metaRowHeight +
    (metaRowHeight ? gap : 0) +
    titleLines.length * titleLine +
    (subtitleLines.length ? gap + subtitleLines.length * subtitleLine : 0) +
    (descLines.length ? gap + descLines.length * descLine : 0) +
    (info ? infoGap + infoSize : 0);

  const topLimit = barHeight + topGap;
  const bottomRowTop = height - bottomGap - iconSize;
  const bottomLimit = bottomRowTop - 24;

  // 海报式排版:信息在上、留白在下。只有 1:1 保持居中。
  const start =
    scale.align === "top"
      ? topLimit
      : Math.round(topLimit + Math.max(0, (bottomLimit - topLimit - blockHeight) / 2));

  let cursor = start;
  const body = [];

  if (meta) {
    body.push(
      `<text x="${pad}" y="${cursor + 22}" font-size="${metaSize}" font-weight="500" fill="${COLORS.muted}" font-family="${FONT}">${escapeXml(meta)}</text>`,
    );
  }
  if (mainLabel) {
    body.push(
      pill({
        x: width - pad,
        y: cursor,
        label: mainLabel,
        fontSize: metaSize,
        height: 32,
        stroke: COLORS.line,
        fill: "none",
        color: COLORS.ink,
        anchorRight: true,
      }),
    );
  }
  if (metaRowHeight) cursor += metaRowHeight + gap;

  body.push(
    textLines(titleLines, {
      x: pad,
      y: cursor + titleSize * 0.9,
      fontSize: titleSize,
      lineHeight: titleLine,
      weight: 700,
      fill: COLORS.ink,
    }),
  );
  cursor += titleLines.length * titleLine;

  if (subtitleLines.length) {
    cursor += gap;
    body.push(
      textLines(subtitleLines, {
        x: pad,
        y: cursor + subtitleSize * 0.85,
        fontSize: subtitleSize,
        lineHeight: subtitleLine,
        weight: 500,
        fill: COLORS.brandText,
      }),
    );
    cursor += subtitleLines.length * subtitleLine;
  }

  if (descLines.length) {
    cursor += gap;
    body.push(
      textLines(descLines, {
        x: pad,
        y: cursor + descSize * 0.9,
        fontSize: descSize,
        lineHeight: descLine,
        weight: 400,
        fill: COLORS.muted,
      }),
    );
    cursor += descLines.length * descLine;
  }

  // 描述与底行之间的一行结构化信息(本机实测 · 0 元 · N 分钟)
  if (info) {
    cursor += infoGap;
    body.push(
      `<text x="${pad}" y="${cursor + infoSize * 0.8}" font-size="${infoSize}" font-weight="500" fill="${COLORS.muted}" font-family="${FONT}">${escapeXml(info)}</text>`,
    );
  }

  const foot = [];
  if (auxLabel) {
    foot.push(
      pill({
        x: pad,
        y: bottomRowTop + 12,
        label: auxLabel,
        fontSize: metaSize,
        height: 32,
        fill: COLORS.sand,
        color: COLORS.ink,
      }),
    );
  }
  if (icon) {
    foot.push(
      `<svg x="${width - pad - iconSize}" y="${bottomRowTop}" width="${iconSize}" height="${iconSize}" viewBox="0 0 32 32">${icon}</svg>`,
    );
  }

  // 卡片轮廓:顶部两角直角(磁条贴死顶角),底部圆角 16。
  const cardPath = `M1,1 H${width - 1} V${height - 17} A16,16 0 0 1 ${width - 17},${height - 1} H17 A16,16 0 0 1 1,${height - 17} Z`;

  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
  ${transparent ? "" : `<rect width="${width}" height="${height}" fill="${COLORS.paper}" />`}
  <rect x="0" y="0" width="${width}" height="${barHeight}" fill="${COLORS.brand}" />

  <text x="${pad}" y="${barHeight / 2 + 14}" font-size="40" font-weight="700" fill="#ffffff" font-family="${FONT}">genwozuo</text>
  <text x="${width - pad}" y="${barHeight / 2 + 8}" font-size="20" font-weight="500" fill="#e4efe9" text-anchor="end" font-family="${FONT}">跟我做</text>

  ${body.join("\n  ")}
  ${foot.join("\n  ")}

  <path d="${cardPath}" fill="none" stroke="${COLORS.line}" stroke-width="2" />
</svg>`;
}

// 有 background 时:先把背景图裁成当前比例、压暗一点,再叠透明版 SVG;
// 没有就普通渲染(SVG 自带纸白底色)。
async function renderCover(entry, filename, size) {
  if (entry.background) {
    const { width, height } = SIZES[size];
    const background = sharp(path.resolve(ROOT, entry.background))
      .resize(width, height, { fit: "cover" })
      .modulate({ brightness: 0.9 });

    await background
      .composite([
        { input: Buffer.from(buildSvg(entry, { size, transparent: true })), blend: "over" },
      ])
      .jpeg({ quality: 92, mozjpeg: true })
      .toFile(filename);

    return;
  }

  await sharp(Buffer.from(buildSvg(entry, { size })))
    .jpeg({ quality: 92, mozjpeg: true })
    .toFile(filename);
}

// node render.mjs --size 9:16 / --ratio 1:1,不传就是 3:4。
function readSize(argv) {
  const index = argv.findIndex((arg) => arg === "--size" || arg === "--ratio");
  if (index === -1) return DEFAULT_SIZE;

  const value = argv[index + 1];
  if (!value || !SIZES[value]) {
    throw new Error(
      `--size 只支持 ${Object.keys(SIZES).join(" / ")},收到:${value ?? "(空)"}`,
    );
  }

  return value;
}

async function main() {
  const size = readSize(process.argv.slice(2));
  const { width, height } = SIZES[size];
  const suffix = size === DEFAULT_SIZE ? "" : `-${width}x${height}`;

  const covers = JSON.parse(await readFile(DATA_FILE, "utf8"));

  if (!Array.isArray(covers) || covers.length === 0) {
    throw new Error(`${DATA_FILE} 里没有可渲染的文案`);
  }

  await mkdir(OUTPUT_DIR, { recursive: true });

  for (const [index, cover] of covers.entries()) {
    const filename = path.join(
      OUTPUT_DIR,
      `cover-${String(index + 1).padStart(2, "0")}${suffix}.jpg`,
    );

    await renderCover(cover, filename, size);
    console.log(`done: ${filename}(${width}×${height})`);
  }
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
  main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
  });
}

运行:

node scripts/render.mjs

三档比例在脚本里写好了,用 --size 切换(不传就是 3:4):

node scripts/render.mjs                # 小红书 3:4,1080×1440
node scripts/render.mjs --size 9:16    # 抖音 / 快手,1080×1920
node scripts/render.mjs --size 1:1     # 闲鱼,1080×1080

换比例时纵向版式按高度等比缩放,横向边距和字号基准不变;非 3:4 的成品文件名会带尺寸后缀(如 cover-01-1080x1920.jpg),避免和默认档互相覆盖。

终端里一条 node scripts/render.mjs 命令,输出前 3 张、省略号、最后一张 cover-20,最后一行是 20 张和真实耗时
① 一次跑完 ② 编号连续,不会漏
20 张封面按 5 列 4 行排开,每张标题不同、版式相同
每张标题不同,版式一样

几个容易踩的坑

1. 文本注入要转义

标题来自 JSON,如果里面有 &、< 或引号,直接拼进 SVG 会破坏结构。上面的 escapeXml 必须保留。

2. 行高和字号要一起算

96px 字号配 128px 行高比较稳。三行标题占总高度约 384px,再留出副标题和底部信息的位置。标题超过三行就该缩字号或拆成两张。

左边 JSON 里标题写了四行,右边封面只显示前三行,第四行没有出现
标题写成四行,脚本只画前三行,第四行被悄悄丢掉——不报错,也不提示

3. 输出格式

小红书封面用 JPG 就够,质量 92 基本看不出压缩。需要透明背景或后续二次编辑时再输出 PNG。

4. 批量生成后要抽查

脚本只能保证尺寸和文字正确,不能保证文案不重复、不敏感。发布前至少抽查前五张,重点看三件事:标题有没有超过三行、副标题有没有被压住、中文有没有变成方框。文件名按顺序生成 cover-01.jpg、cover-02.jpg…,重复跑会直接覆盖;想留版本,就把 output/ 换成带日期的目录。

想加背景图怎么办

在 JSON 里加一个 background 字段,填相对脚本所在目录的图片路径就行,代码不用改——上面的脚本里 renderCover 已经处理好了:

  • 有 background 时,先用 Sharp 把背景图裁成当前比例(默认 1080×1440)、亮度压到 0.9,再叠加 buildSvg(cover, { transparent: true }) 生成的透明版 SVG;
  • 没有 background 时走普通流程,SVG 自带不透明底色。

关键在 transparent 选项:有背景图时不要再铺那层不透明的底色,否则背景会被完全盖住。卡片底色用的是 fill-opacity="0.92",让背景能透出一点。

如果背景太花,在 buildSvg 里加一层半透明白色矩形,文字会更清楚。

什么时候该换方案

这套方案适合版式稳定、只换文案的批量封面。遇到下面情况,换成 HTML + Playwright 更合适:

  • 需要复杂排版、网格、多字体混排。
  • 需要引用网页字体或远程图片。
  • 需要精确还原设计稿。

但如果你只是想把 20 条标题快速变成 20 张能用的封面,SVG 方案更轻,也更容易维护。

下一步读什么

封面出完,上传前用批量压缩图片把体积压到合适;需要统一标识就加批量水印。

常见问题

为什么用 SVG 而不是直接截图 HTML?

SVG 不需要浏览器,渲染快、依赖少,适合批量。需要复杂排版或网页字体时,再换成 Playwright 截 HTML 更灵活。

中文字体渲染不出来怎么办?

Sharp 依赖系统字体。先在系统里安装思源黑体或苹方,再在 SVG 的 font-family 里写字体名。服务器上建议安装 Noto Sans CJK。

一次生成 20 张会不会很慢?

纯文字模板通常几秒内完成。主要耗时在图片合成和磁盘写入,和图片分辨率、数量成正比。

能自动配图吗?

可以。给每条文案加一个背景图路径,在合成前把背景图铺进 SVG 或作为 Sharp 的底层,再叠加文字层。

每周一张复习表

每周分享一张实用复习表,不收钱不打扰,想走随时走

邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。

同方向继续

全部教程