生成动态日签图-前端实现和后端实现
需求讲解
根据业务的需求,需要把一些动态信息(人名、时间等)和图片资源生成一张图片,使用户能够保存到本地使用、或分享(不能粗暴的让用户截图)。
经过了技术调研,决定使用 canvas 进行文字信息和图片资源的拼接,转换为新图片。用户可以长按保存或者调用客户端(微信小程序或其他)的保存图片接口实现图片保存。
笔者这里实施了两套方案,分别是:在前端使用 canvas 进行图片生成、和使用 NodeJS 在后端进行图片生成。这里先说结论:建议使用后端生成。
前端实现(不推荐)
思路
使用 canvas.toDataURL('image/png')
将画布转换为 Base64 格式的图片地址。供 img
标签进行渲染,然后用户在移动端使用原生的长按菜单交互进行保存,或者调用客户端(微信小程序或其他)的保存图片接口实现图片保存。
代码
<template>
<div class="draw-demo">
<canvas ref="canvas"></canvas>
<img ref="img" src="" alt="">
<div class="save">长按保存到相册</div>
</div>
</template>
<script>
import './fonts/FZLBJW.TTF';
const BASE_WID = 1334;
const BASE_HEI = 750;
const phoneWidth = window.screen.width;
const phoneHeight = window.screen.height;
const xRadio = phoneWidth / BASE_WID;
const yRadio = phoneHeight / BASE_HEI;
const t = {
x: (inVal) => {
return inVal * xRadio;
},
y: (inVal) => {
return inVal * yRadio;
},
};
const translateWidth = phoneWidth;
const translateHeight = t.y(568.71);
/**
* @author zhangxinxu(.com)
* @licence MIT
* @description http://www.zhangxinxu.com/wordpress/?p=7362
*/
CanvasRenderingContext2D.prototype.fillTextVertical = function (
text,
x,
y,
) {
var context = this;
// var canvas = context.canvas;
var arrText = text.split('');
var arrWidth = arrText.map(function (letter) {
return context.measureText(letter).width;
});
var align = context.textAlign;
var baseline = context.textBaseline;
if (align === 'left') {
x = x + Math.max.apply(null, arrWidth) / 2;
} else if (align === 'right') {
x = x - Math.max.apply(null, arrWidth) / 2;
}
if (
baseline === 'bottom'
|| baseline === 'alphabetic'
|| baseline === 'ideographic'
) {
y = y - arrWidth[0] / 2;
} else if (
baseline === 'top'
|| baseline === 'hanging'
) {
y = y + arrWidth[0] / 2;
}
context.textAlign = 'center';
context.textBaseline = 'middle';
// 开始逐字绘制
arrText.forEach(function (letter, index) {
// 确定下一个字符的纵坐标位置
var letterWidth = arrWidth[index];
// 是否需要旋转判断
var code = letter.charCodeAt(0);
if (code <= 256) {
context.translate(x, y);
// 英文字符,旋转90°
context.rotate(90 * Math.PI / 180);
context.translate(-x, -y);
} else if (index > 0 && text.charCodeAt(index - 1) < 256) {
// y修正
y = y + arrWidth[index - 1] / 2;
}
context.fillText(letter, x, y);
// 旋转坐标系还原成初始态
context.setTransform(1, 0, 0, 1, 0, 0);
// 确定下一个字符的纵坐标位置
// var letterWidth = arrWidth[index];
y = y + letterWidth;
});
// 水平垂直对齐方式还原
context.textAlign = align;
context.textBaseline = baseline;
};
const waitImgLoad = (img) => {
return new Promise(resolve => {
img.onload = resolve;
});
};
export default {
props: {
stu: Object,
},
async mounted () {
await this._draw();
// await this._cavToImg();
},
methods: {
async _draw () {
const xStart = 0;
const yStart = (phoneHeight - translateHeight) / 2;
const canvas = this.$refs.canvas;
const ctx = canvas.getContext('2d');
canvas.width = phoneWidth;
canvas.height = phoneHeight;
// 底色
ctx.fillStyle = '#CC9965';
ctx.fillRect(0, 0, phoneWidth, phoneHeight);
// 轴布
ctx.fillStyle = '#FDF7F2';
ctx.fillRect(
xStart, yStart,
translateWidth, translateHeight
);
// 山水背景
const imgInkBg = new Image();
imgInkBg.src = require('./imgs/ink-bg.png');
await waitImgLoad(imgInkBg);
ctx.drawImage(
imgInkBg,
t.x(-51), t.y(379),
t.x(1402), t.y(469),
);
// 右上角竹子
const imgRightTop = new Image();
imgRightTop.src = require('./imgs/bamboo.png');
await waitImgLoad(imgRightTop);
ctx.drawImage(
imgRightTop,
translateWidth - t.x(135), yStart,
t.x(135), t.y(233),
// 198, 263,
);
// 标题
// 描框
ctx.lineWidth = 2;
ctx.strokeStyle = '#ccaca9';
ctx.strokeRect(
t.x(1163), t.y(163),
t.x(120), t.y(400),
);
// 填字
const imgRightTitle = new Image();
imgRightTitle.src = require('./imgs/right-title.png');
await waitImgLoad(imgRightTitle);
ctx.drawImage(
imgRightTitle,
t.x(1196), t.y(184),
t.x(64), t.y(358),
);
// 口号渲染
const imgRightSlogan = new Image();
imgRightSlogan.src = require('./imgs/right-slogan.png');
await waitImgLoad(imgRightSlogan);
ctx.drawImage(
imgRightSlogan,
t.x(986), t.y(303),
t.x(122), t.y(261),
);
// 祝贺致辞
const imgRightWelcome = new Image();
imgRightWelcome.src = require('./imgs/right-welcome.png');
await waitImgLoad(imgRightWelcome);
ctx.drawImage(
imgRightWelcome,
t.x(834), t.y(147),
t.x(113), t.y(211),
);
ctx.font = `${16}px FZLBJW`;
ctx.fillStyle = '#000';
ctx.fillTextVertical(
'同学被预录取为我校 学',
t.x(780),
t.y(350)
);
},
async _cavToImg () {
// 转换为内联
const canvas = this.$refs.canvas;
const insertTarget = this.$refs.img;
insertTarget.src = canvas.toDataURL('image/png');
insertTarget.classList.add('img');
canvas.style.visibility = 'hidden';
},
},
};
</script>
<style lang="less">
@font-face {
font-family: 'FZLBJW';
src: url('./fonts/FZLBJW.TTF');
}
.FZLBJW {
font-family: FZLBJW;
}
</style>
<style lang="less" scoped>
.draw-demo {
position: relative;
width: 100%;
height: 100%;
.img {
position: absolute;
width: 100%;
left: 0;
top: 0;
z-index: 2;
}
.save {
position: absolute;
right: 40px;
bottom: 30px;
padding: 6px 12px;
background: #7F0C00;
color: #fff;
border-radius: 4PX;
box-shadow:
0px 3px 6px -4px rgba(0, 0, 0, 0.12),
0px 6px 16px 0px rgba(0, 0, 0, 0.08),
0px 9px 28px 8px rgba(0, 0, 0, 0.05);
z-index: 4;
pointer-events: none;
}
}
</style>
重难点解析
上面提供的是涉及场景最多的代码示例,分别是:
- 坐标系按比例转换
- 自定义字体引入和画布渲染
- canvas的字体垂直排版
- 异步引入图片的处理
这里不一一解释,先贴我做时查到的资料。
异步引入图片的处理
代码中的图片都是在 onload
事件之后才会被绘制到画布上的,第一版代码还没用 await
来进行阻塞,出现了先绘制的图片挡住了后绘制的文字的情况(其实是我看代码执行顺序没看对),所以后面改成了异步阻塞的做法,当然有好有不好。
自定义字体引入
这里的自定义字体引入方式是错误的,canvas 的机制是:这个字体被注册,而且字体文件加载完成的时候,才能在绘制的那一刻加载正确的字体,而不会在字体加载完成后自动更新之前使用这个字体绘制的部分。
所以我把字体注册的引入放到了 head 标签里面,进入该页面前还有很长的用户信息输入界面,所以基本能保证在绘制时字体已加载完成。
不推荐的原因
遇到一个情况:图片的 base64 链接已经生成了、图片元素的体积正常、但是图片就是没有渲染,表现为透明的图片、直到我点击 vconsole 的调试弹窗图片才姗姗来迟。
原因至今没找到,怀疑是 base64 超长,所以后面改用了后端生成。
后端实现
思路
使用 NodeJS 环境的 canvas 库进行 canvas 的模拟和图片生成,然后前端的 img 标签通过接口传递信息来获取图片流渲染。
代码
// app/service/draw.js
'use strict';
const Service = require('egg').Service;
const path = require('path');
const { createCanvas, loadImage, registerFont } = require('canvas');
/**
* @author zhangxinxu(.com)
* @licence MIT
* @description http://www.zhangxinxu.com/wordpress/?p=7362
*/
function fillTextVertical(
text,
x,
y
) {
const context = this;
// var canvas = context.canvas;
const arrText = text.split('');
const arrWidth = arrText.map(function(letter) {
return context.measureText(letter).width;
});
const align = context.textAlign;
const baseline = context.textBaseline;
if (align === 'left') {
x = x + Math.max.apply(null, arrWidth) / 2;
} else if (align === 'right') {
x = x - Math.max.apply(null, arrWidth) / 2;
}
if (
baseline === 'bottom'
|| baseline === 'alphabetic'
|| baseline === 'ideographic'
) {
y = y - arrWidth[0] / 2;
} else if (
baseline === 'top'
|| baseline === 'hanging'
) {
y = y + arrWidth[0] / 2;
}
context.textAlign = 'center';
context.textBaseline = 'middle';
// 开始逐字绘制
arrText.forEach(function(letter, index) {
// 确定下一个字符的纵坐标位置
const letterWidth = arrWidth[index];
// 是否需要旋转判断
const code = letter.charCodeAt(0);
if (code <= 256) {
context.translate(x, y);
// 英文字符,旋转90°
context.rotate(90 * Math.PI / 180);
context.translate(-x, -y);
} else if (index > 0 && text.charCodeAt(index - 1) < 256) {
// y修正
y = y + arrWidth[index - 1] / 2;
}
context.fillText(letter, x, y);
// 旋转坐标系还原成初始态
context.setTransform(1, 0, 0, 1, 0, 0);
// 确定下一个字符的纵坐标位置
// var letterWidth = arrWidth[index];
y = y + letterWidth;
});
// 水平垂直对齐方式还原
context.textAlign = align;
context.textBaseline = baseline;
}
class DrawService extends Service {
/**
* 绘制通知书
* @param {Object} user 用户实例
*/
async index({
width,
height,
name,
department,
grade,
}) {
// const { ctx } = this;
const BASE_WID = 1334;
const BASE_HEI = 750;
const phoneWidth = BASE_WID;
const phoneHeight = BASE_WID * height / width;
const xRadio = phoneWidth / BASE_WID;
const yRadio = phoneHeight / BASE_HEI;
const t = {
x: inVal => {
return inVal * xRadio;
},
y: inVal => {
return inVal * yRadio;
},
};
registerFont(
path.resolve(__dirname, '../res/fonts/FZLBJW.TTF'),
{ family: 'FZLBJW' }
);
const canvas = createCanvas(phoneWidth, phoneHeight);
const context = canvas.getContext('2d');
context.fillTextVertical = fillTextVertical;
// 底色
context.fillStyle = '#CC9965';
context.fillRect(0, 0, phoneWidth, phoneHeight);
// 底图
const imgBackground = await loadImage(path.resolve(__dirname, '../res/imgs/all.png'));
context.drawImage(
imgBackground,
0, (phoneHeight - t.y(660)),
t.x(1334), t.y(660)
);
context.font = '28px FZLBJW';
context.fillStyle = '#7F0C00';
context.fillTextVertical(
name,
t.x(784), (phoneHeight - t.y(660) + t.y(140))
);
context.fillTextVertical(
department,
t.x(784), (phoneHeight - t.y(660) + t.y(496))
);
context.fillTextVertical(
grade,
t.x(722), (phoneHeight - t.y(660) + t.y(84))
);
return canvas;
}
}
module.exports = DrawService;
// app/controller/home.js
'use strict';
const Controller = require('egg').Controller;
class HomeController extends Controller {
async draw() {
const { ctx, service } = this;
ctx.validate({
width: { type: 'number' },
height: { type: 'number' },
stuId: { type: 'string' },
}, ctx.request.query);
const { width, height, stuId } = ctx.request.query;
const stu = await ctx.model.Student.findByPk(stuId);
if (!stu) {
return service.utils.returnError(1, '抱歉,没有此学生的信息');
}
const canvas = await service.draw.index({
width,
height,
name: stu.get('name'),
department: stu.get('department'),
grade: stu.get('grade'),
});
ctx.type = 'image/jpeg';
ctx.body = canvas.toBuffer('image/jpeg', { quality: 0.6 });
return;
}
}
module.exports = HomeController;
重难点解析
这里用的是 eggjs 作为后端框架 service 是画布渲染、controller 是业务逻辑。
其中重难点有这些:canvas 的安装、canvas 库与浏览器端接口的差异、接口返回参数的配置。
canvas 的安装
原生的 npm install canvas
安装方式可能需要科学上网,特别慢、所以采用本地编译的方式。
按照文档系统安装了对应系统平台需要的前置依赖之后,执行 npm install --build-from-source canvas
本地编译
canvas 库与浏览器端接口的差异
总体来说 canvas 库提供的接口完爆浏览器端,不但处理了图片异步加载(loadImage
)、还提供了同步注册外部字体(registerFont
)、和将画布转换为二进制流并压缩图片的接口和配置(toBuffer
),再看看前端实现过程踩过的坑,感觉十分舒适。