小程序分享海报生成
生成朋友圈/群分享海报的完整工具函数:渐变背景、圆形头像、昵称、二维码、导出保存。
在线演示:Canvas 2D 海报绘制
等待操作:点击「生成海报」在画布上绘制,再点「下载 PNG」导出。
代码示例:离屏海报绘制
js
// utils/poster.js —— Canvas 2D 分享海报生成
async function drawPoster({ canvas, qrPath, avatarPath, name }) {
const ctx = canvas.getContext("2d");
const dpr = wx.getWindowInfo().pixelRatio;
const W = 600, H = 900;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
// 背景
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, "#1b2f63");
bg.addColorStop(1, "#0e1730");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// 头像(先下载成本地路径再画)
const avatar = await loadImage(avatarPath);
ctx.save();
ctx.beginPath();
ctx.arc(W / 2, 200, 60, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(avatar, W / 2 - 60, 140, 120, 120);
ctx.restore();
// 昵称
ctx.fillStyle = "#ffffff";
ctx.font = "bold 34px sans-serif";
ctx.textAlign = "center";
ctx.fillText(name, W / 2, 330);
// 二维码
const qr = await loadImage(qrPath);
ctx.drawImage(qr, W / 2 - 110, 560, 220, 220);
// 导出临时文件
return new Promise((resolve) => {
wx.canvasToTempFilePath({
canvas,
success: (res) => resolve(res.tempFilePath),
fail: () => resolve("")
});
});
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = wx.createImage();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
module.exports = { drawPoster };
操作步骤
- 用离屏
canvas(type="2d")绘制海报:背景、头像、昵称、二维码。 - 网络图片先
wx.downloadFile到本地再绘制,避免 canvas 跨域污染。 canvasToTempFilePath导出临时图,wx.saveImageToPhotosAlbum保存相册。- 授权失败时引导用户打开设置页手动授权。
代码示例:布局计算与文本换行(可单测的纯逻辑)
js
/* poster-core.js —— 不依赖 wx,可在电脑上直接测 */
function canvasSize(design, dpr) {
var d = Number(dpr) || 0;
if (d <= 0) d = 1; // 非法 DPR 兜底
return { width: Math.round(design.width * d), height: Math.round(design.height * d), dpr: d };
}
function layout(design) {
var W = design.width, H = design.height, pad = design.padding == null ? 48 : design.padding;
var avatarR = (design.avatarSize == null ? 120 : design.avatarSize) / 2;
var qrSize = design.qrSize == null ? 220 : design.qrSize;
return {
avatar: { x: W / 2, y: pad + avatarR, r: avatarR }, // 水平居中
name: { x: W / 2, y: pad + avatarR * 2 + 56, size: 36 },
qr: { x: (W - qrSize) / 2, y: H - pad - qrSize - 36, size: qrSize }, // 距底留白
tip: { x: W / 2, y: H - pad, size: 22 }
};
}
function wrapText(text, maxWidth, measure) { // measure 由调用方注入,方便测试
var s = String(text == null ? "" : text);
if (!s) return [""];
var lines = [], cur = "";
for (var i = 0; i < s.length; i++) {
var next = cur + s[i];
if (cur && measure(next) > maxWidth) { lines.push(cur); cur = s[i]; }
else cur = next;
}
if (cur) lines.push(cur);
return lines;
}完整可运行工程
可直接运行(Node 自测)真机绘制需小程序环境零第三方依赖
运行环境:Node.js ≥ 12(自测);真机绘制需微信基础库 2.9.0+ 的 Canvas 2D。
先在电脑上跑通:cd miniapp-poster && node tools/selftest.js,预期输出 ALL CHECKS PASSED(覆盖 DPR 尺寸换算、头像/二维码居中与底部留白、文本换行边界、圆角与圆形路径闭合、相册文件名唯一性)。
真机使用需改:poster.js 的 DESIGN 与配色、WXML 里的 <canvas type="2d" id="poster">,并在小程序后台配置 downloadFile 合法域名。
miniapp-poster/
├── README.md 运行步骤 / 配置项 / 依赖清单 / 常见坑
├── package.json 自测入口(npm test),零依赖
├── miniprogram/utils/
│ ├── poster-core.js 布局 / 换行 / 路径纯逻辑(UMD,可单测)
│ ├── poster.js 真机绘制 + 导出 + 保存相册 + 授权兜底
│ └── load-image.js 图片下载与缓存(网络图先落地再画)
└── tools/selftest.js Node 最小用例:DPR / 布局 / 换行 / 路径 / 文件名
README.md
运行步骤、需先改的配置项、依赖清单与真机常见坑(DPR、跨域、授权)。
markdown
# miniapp-poster · 小程序分享海报生成(可运行工程)
朋友圈 / 群分享海报的完整生成工具:**渐变背景 + 圆形头像 + 昵称 + 二维码 + 文案换行 + 导出保存**。
工程把「布局计算」与「Canvas 绘制」拆开:
- `poster-core.js`:纯逻辑(画布 DPR 尺寸、元素布局、文本换行、圆角/圆形路径、文件名生成)—— 不依赖 `wx`,可在电脑上直接测试;
- `poster.js`:真机绘制(Canvas 2D + `canvasToTempFilePath` + 保存相册 + 授权兜底);
- `load-image.js`:网络图片下载与内存缓存(避免 canvas 跨域污染)。
## 目录结构
```
miniapp-poster/
├── README.md 本文件:运行步骤 / 配置项 / 依赖清单
├── package.json 自测入口(npm test),dependencies 为空
├── miniprogram/utils/
│ ├── poster-core.js 布局与换行纯逻辑(UMD,Node 可直接 require)
│ ├── poster.js 真机绘制:drawPoster / generate / saveToAlbum
│ └── load-image.js 图片下载与缓存(网络图先落地再画)
└── tools/selftest.js Node 最小用例:布局 / 换行 / 路径 / 文件名
```
## 可直接运行(无需真机)
```bash
cd miniapp-poster
node tools/selftest.js # 或 npm test
```
预期输出最后一行 `ALL CHECKS PASSED`,覆盖:
1. DPR 换算:600×900 设计稿在 `pixelRatio=2` 下画布为 1200×1800(真机不糊的关键);
2. 布局居中:头像 / 昵称 / 二维码均水平居中,二维码距底边留出说明文字空间;
3. 文本换行:等宽样例下按行宽切分,空文本返回单空行,超长文案行数正确;
4. 圆角矩形与圆形裁剪路径调用完整闭合(mock ctx 校验调用序列);
5. 文件名生成:同一时间生成结果稳定,不会因重名覆盖相册旧图。
## 需先改的配置项
| 位置 | 配置项 | 说明 |
|---|---|---|
| `poster.js` | `DESIGN` | 海报设计稿尺寸(宽/高/头像直径/二维码边长/边距),改这里即可换版式 |
| `poster.js` | 渐变色 `#1b2f63 → #0e1730` | 与小程序主题色保持一致 |
| `poster.js` | `BG_IMAGE`(可选) | 想用底图而非纯渐变时,填入背景图地址 |
| `poster.js` | 昵称/文案来源 | 默认取用户资料与页面参数,接入时改为你的接口字段 |
| `load-image.js` | `CACHE_LIMIT` | 图片缓存条目上限,按分享量调整 |
| 小程序后台 | `downloadFile` 合法域名 | 头像 / 二维码若为网络图,域名必须提前配置 |
## 依赖清单
- 小程序端:微信基础库 **2.9.0+**(Canvas 2D 接口),**无第三方依赖**;
- 电脑端自测:Node.js ≥ 12,**零 npm 依赖**(`dependencies` 为空);
- 真机用到的 API:`wx.createSelectorQuery().fields({node:true,size:true})`、
`wx.canvasToTempFilePath`、`wx.saveImageToPhotosAlbum`、`wx.getImageInfo`、`wx.getWindowInfo`。
## 常见坑
- **必须**用 `type="2d"` 的新版 canvas,旧版 `wx.createCanvasContext` 已废弃且不支持 `ctx.font` 度量;
- 网络图片必须先 `wx.downloadFile` / `wx.getImageInfo` 转成临时路径再 `drawImage`,否则真机白图;
- 画布尺寸要乘 `pixelRatio` 并 `ctx.scale(dpr, dpr)`,否则导出图模糊;
- `saveImageToPhotosAlbum` 被拒后不要再弹授权框,应引导用户到 `wx.openSetting` 手动开启;
- 海报上的二维码建议由后端生成(带场景值),避免小程序端打包体积膨胀。
miniprogram/utils/poster-core.js
纯逻辑层(UMD):画布 DPR 尺寸、元素布局、文本换行、圆角/圆形路径、文件名。
js
/* poster-core.js —— 海报布局纯逻辑(UMD:小程序 require / 电脑端 Node 直接测试) */
(function (root, factory) {
if (typeof module === "object" && module.exports) module.exports = factory();
else root.PosterCore = factory();
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
/* 设计稿尺寸 → 画布物理尺寸:乘 DPR,避免真机导出模糊 */
function canvasSize(design, dpr) {
var d = Number(dpr) || 0;
if (d <= 0) d = 1;
return { width: Math.round(design.width * d), height: Math.round(design.height * d), dpr: d };
}
function scale(v, dpr) {
var d = Number(dpr) || 0;
if (d <= 0) d = 1;
return Math.round(v * d);
}
/* 海报元素布局:全部返回"设计稿坐标系",绘制前统一乘 dpr */
function layout(design) {
var W = design.width, H = design.height;
var pad = design.padding == null ? 48 : design.padding;
var avatarD = design.avatarSize == null ? 120 : design.avatarSize;
var qrSize = design.qrSize == null ? 220 : design.qrSize;
var avatarR = avatarD / 2;
var avatarCy = pad + avatarR;
return {
avatar: { x: W / 2, y: avatarCy, r: avatarR, d: avatarD },
name: { x: W / 2, y: avatarCy + avatarR + 56, size: 36 },
slogan: { x: W / 2, y: avatarCy + avatarR + 108, size: 26, maxWidth: W - pad * 2 },
qr: { x: (W - qrSize) / 2, y: H - pad - qrSize - 36, size: qrSize },
tip: { x: W / 2, y: H - pad, size: 22 }
};
}
/* 文本换行:measure(text) 由调用方注入(真机 ctx.measureText,测试用等宽近似) */
function wrapText(text, maxWidth, measure) {
var s = String(text == null ? "" : text);
if (!s) return [""];
var lines = [], cur = "";
for (var i = 0; i < s.length; i++) {
var next = cur + s[i];
if (cur && measure(next) > maxWidth) { lines.push(cur); cur = s[i]; }
else cur = next;
}
if (cur) lines.push(cur);
return lines;
}
/* 圆角矩形路径:Canvas 2D 未内置 roundRect 时使用 */
function roundRectPath(ctx, x, y, w, h, r) {
var rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
return ctx;
}
/* 圆形裁剪路径(头像 / 二维码圆角底) */
function circlePath(ctx, x, y, r) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.closePath();
return ctx;
}
/* 相册文件名:带日期时间,避免同名覆盖旧海报 */
function posterFileName(date, prefix) {
var d = date || new Date();
function p(n) { return ("0" + n).slice(-2); }
return (prefix || "poster") + "_" + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate())
+ "_" + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + ".png";
}
return {
canvasSize: canvasSize, scale: scale, layout: layout, wrapText: wrapText,
roundRectPath: roundRectPath, circlePath: circlePath, posterFileName: posterFileName
};
});
miniprogram/utils/poster.js
真机绘制:渐变背景、圆形头像、昵称与文案换行、二维码白底、导出与保存相册。
js
/* poster.js —— 真机绘制:Canvas 2D 画海报 + 导出 + 保存相册(依赖 poster-core.js) */
"use strict";
const core = require("./poster-core.js");
const { loadImage } = require("./load-image.js");
/* 需先改:海报设计稿尺寸与配色 */
const DESIGN = {
width: 600,
height: 900,
avatarSize: 120,
qrSize: 220,
padding: 48
};
const BG_FROM = "#1b2f63";
const BG_TO = "#0e1730";
/* 拿到离屏 canvas 节点(WXML 需写 <canvas type="2d" id="poster" />) */
function getCanvasNode(selector) {
return new Promise((resolve, reject) => {
wx.createSelectorQuery()
.select(selector || "#poster")
.fields({ node: true, size: true })
.exec((res) => {
const node = res && res[0] && res[0].node;
node ? resolve(node) : reject(new Error("未找到 canvas 节点,请检查 type=\"2d\" 与 id"));
});
});
}
/* 绘制海报(返回 Promise,全部异步资源加载完成后再导出) */
async function drawPoster(opt) {
const node = opt.canvas || (await getCanvasNode(opt.selector));
const dpr = (wx.getWindowInfo && wx.getWindowInfo().pixelRatio) || 2;
const size = core.canvasSize(DESIGN, dpr);
const L = core.layout(DESIGN);
const ctx = node.getContext("2d");
node.width = size.width;
node.height = size.height;
ctx.scale(dpr, dpr);
/* 1) 渐变背景 */
const bg = ctx.createLinearGradient(0, 0, 0, DESIGN.height);
bg.addColorStop(0, BG_FROM);
bg.addColorStop(1, BG_TO);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, DESIGN.width, DESIGN.height);
/* 2) 圆形头像(网络图先落地成本地路径,避免跨域污染) */
if (opt.avatarPath) {
const avatar = await loadImage(opt.avatarPath);
ctx.save();
core.circlePath(ctx, L.avatar.x, L.avatar.y, L.avatar.r);
ctx.clip();
ctx.drawImage(avatar, L.avatar.x - L.avatar.r, L.avatar.y - L.avatar.r, L.avatar.d, L.avatar.d);
ctx.restore();
}
/* 3) 昵称(居中,过长自动换行) */
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold " + L.name.size + "px sans-serif";
const nameLines = core.wrapText(opt.name || "", L.slogan.maxWidth, (t) => ctx.measureText(t).width);
nameLines.forEach((line, i) => {
ctx.fillText(line, L.name.x, L.name.y + i * (L.name.size + 8));
});
/* 4) 说明文案 */
ctx.font = L.slogan.size + "px sans-serif";
ctx.fillStyle = "rgba(255,255,255,0.82)";
const tipLines = core.wrapText(opt.slogan || "长按识别二维码,一起来玩", L.slogan.maxWidth, (t) => ctx.measureText(t).width);
tipLines.forEach((line, i) => {
ctx.fillText(line, L.slogan.x, L.slogan.y + i * (L.slogan.size + 8));
});
/* 5) 二维码(白色圆角底 + 二维码本体) */
if (opt.qrPath) {
const qr = await loadImage(opt.qrPath);
ctx.fillStyle = "#ffffff";
core.roundRectPath(ctx, L.qr.x - 12, L.qr.y - 12, L.qr.size + 24, L.qr.size + 24, 16);
ctx.fill();
ctx.drawImage(qr, L.qr.x, L.qr.y, L.qr.size, L.qr.size);
}
/* 6) 底部提示 */
ctx.font = L.tip.size + "px sans-serif";
ctx.fillStyle = "rgba(255,255,255,0.6)";
ctx.fillText("nocau · 小程序分享海报", L.tip.x, L.tip.y);
return node;
}
/* 导出临时图 → 保存到相册(授权被拒时引导手动开启) */
async function saveToAlbum(opt) {
const node = await drawPoster(opt);
const tempFilePath = await new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas: node,
fileType: "png",
success: (res) => resolve(res.tempFilePath),
fail: (err) => reject(new Error("导出失败:" + (err.errMsg || "")))
});
});
return new Promise((resolve, reject) => {
wx.saveImageToPhotosAlbum({
filePath: tempFilePath,
success: () => resolve({ ok: true, filePath: tempFilePath, fileName: core.posterFileName(new Date()) }),
fail: (err) => {
if (/auth deny|authorize/.test(err.errMsg || "")) {
wx.showModal({
title: "需要相册权限",
content: "请在设置中允许保存到相册后重试",
confirmText: "去设置",
success: (r) => { if (r.confirm) wx.openSetting(); }
});
}
reject(new Error(err.errMsg || "保存失败"));
}
});
});
}
module.exports = { DESIGN: DESIGN, getCanvasNode: getCanvasNode, drawPoster: drawPoster, saveToAlbum: saveToAlbum };
miniprogram/utils/load-image.js
网络图片下载转本地路径 + 内存缓存,避免 canvas 跨域污染。
js
/* load-image.js —— 图片下载与缓存:网络图先落地成本地路径再交给 canvas 绘制 */
"use strict";
const CACHE_LIMIT = 30; // 需先改(可选):缓存条目上限,按分享量调整
const cache = {}; // { src: { path, at } }
let keys = [];
function remember(src, path) {
if (!cache[src]) keys.push(src);
cache[src] = { path: path, at: Date.now() };
while (keys.length > CACHE_LIMIT) delete cache[keys.shift()];
}
/* 网络图 → 本地临时路径(画布 drawImage 只接受本地路径) */
function toLocalPath(src) {
if (!/^https?:\/\//.test(src)) return Promise.resolve(src); // 已是本地路径
const hit = cache[src];
if (hit) return Promise.resolve(hit.path);
return new Promise((resolve, reject) => {
wx.getImageInfo({
src: src,
success: (res) => { remember(src, res.path); resolve(res.path); },
fail: (err) => reject(new Error("图片下载失败:" + (err.errMsg || src)))
});
});
}
/* 加载可被 canvas 使用的图片对象 */
function loadImage(src) {
return toLocalPath(src).then((path) => new Promise((resolve, reject) => {
const img = wx.createImage();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("图片解码失败:" + path));
img.src = path;
}));
}
function clearCache() { keys.forEach((k) => delete cache[k]); keys = []; }
module.exports = { loadImage: loadImage, toLocalPath: toLocalPath, clearCache: clearCache, CACHE_LIMIT: CACHE_LIMIT };
tools/selftest.js
电脑端最小用例:5 组断言,全部离线执行。
js
/* tools/selftest.js —— 零依赖最小用例:node tools/selftest.js */
"use strict";
const assert = require("assert");
const path = require("path");
const core = require(path.join(__dirname, "..", "miniprogram", "utils", "poster-core.js"));
const DESIGN = { width: 600, height: 900, avatarSize: 120, qrSize: 220, padding: 48 };
/* 1) DPR 换算:真机不糊的关键 */
assert.deepStrictEqual(core.canvasSize(DESIGN, 2), { width: 1200, height: 1800, dpr: 2 });
assert.deepStrictEqual(core.canvasSize(DESIGN, 3), { width: 1800, height: 2700, dpr: 3 });
assert.strictEqual(core.canvasSize(DESIGN, undefined).width, 600, "缺省 DPR 按 1 处理");
assert.strictEqual(core.canvasSize(DESIGN, 0).dpr, 1, "非法 DPR 兜底 1");
assert.strictEqual(core.scale(33, 2), 66);
/* 2) 布局:水平居中 + 二维码底部留白 */
const L = core.layout(DESIGN);
assert.strictEqual(L.avatar.x, 300, "头像水平居中");
assert.strictEqual(L.avatar.y, 108, "头像圆心 = 边距 + 半径");
assert.strictEqual(L.name.x + L.slogan.x + L.tip.x, 900, "文字元素均居中");
assert.strictEqual(L.qr.x + L.qr.size / 2, 300, "二维码水平居中");
assert.strictEqual(L.qr.y + L.qr.size + 36, 900 - 48, "二维码下方留出底部提示空间");
/* 3) 文本换行:等宽近似 measure(每字 20px) */
const measure = (t) => t.length * 20;
assert.deepStrictEqual(core.wrapText("一二三四五六七八九十十一", 100, measure),
["一二三四五", "六七八九十", "十一"]);
assert.deepStrictEqual(core.wrapText("", 100, measure), [""], "空文案返回单空行");
assert.deepStrictEqual(core.wrapText(null, 100, measure), [""]);
assert.deepStrictEqual(core.wrapText("短", 100, measure), ["短"]);
assert.strictEqual(core.wrapText("昵称昵称昵称昵称", 40, measure).length, 4, "每行 2 字,8 字共 4 行");
/* 4) 路径:mock ctx 校验调用序列完整闭合 */
const calls = [];
const mock = {
beginPath: () => calls.push("beginPath"),
moveTo: () => calls.push("moveTo"),
lineTo: () => calls.push("lineTo"),
arcTo: () => calls.push("arcTo"),
arc: () => calls.push("arc"),
closePath: () => calls.push("closePath")
};
core.roundRectPath(mock, 10, 10, 200, 120, 16);
assert.strictEqual(calls[0], "beginPath");
assert.strictEqual(calls[calls.length - 1], "closePath");
assert.strictEqual(calls.filter((c) => c === "arcTo").length, 4, "四角圆角");
calls.length = 0;
core.circlePath(mock, 300, 108, 60);
assert.deepStrictEqual(calls, ["beginPath", "arc", "closePath"]);
/* 5) 文件名:可预期且不重名 */
const fn = core.posterFileName(new Date(2026, 8, 19, 9, 5, 7));
assert.strictEqual(fn, "poster_20260919_090507.png");
assert.strictEqual(core.posterFileName(new Date(2026, 8, 19, 9, 5, 8)), "poster_20260919_090508.png");
assert.strictEqual(core.posterFileName(new Date(2026, 8, 19, 9, 5, 7), "share"), "share_20260919_090507.png");
console.log("ALL CHECKS PASSED");
package.json
依赖清单(dependencies 为空)与 npm test 入口。
json
{
"name": "miniapp-poster",
"version": "1.0.0",
"private": true,
"description": "小程序分享海报生成:布局逻辑可单测,真机绘制与保存相册完整",
"scripts": {
"test": "node tools/selftest.js"
},
"dependencies": {}
}
最小用例实跑输出:$ node tools/selftest.js → ALL CHECKS PASSED;真机绘制与保存相册需在小程序内运行(本页布局与换行逻辑已离线验证)。