H5 canvas 生成图片并上传文件转成 PDF 下载
最近遇到一个业务需求,在小程序端定制预览功能,并在预览的图片中使用指定的外部字体。将预览的图片上传 OSS,后端生成 PDF,在管理系统中下载。
但是…………,经过实践发现,小程序尽管做了分包处理,依旧不能在本地存放字体包,把字体放 OSS 上返回,但是出现跨域,尽管配置了允许跨域,依旧不行。而且!!!小程序的 canvas API
没法设置字体,没有 h5 中 canvas 中的 context.font = '字体名称'
方法。最终决定曲线救国,放弃小程序端的预览生成 canvas 功能,将 canvas 引入字体,生成图片等操作放在管理系统中,采用原生 canvas
来实现。
技术要点
- canvas 文字排版
- canvas 设置指定背景颜色
- canvas 引入外部字体
- canvas 绘制文字图片
- 将 canvas 生成的 base64 图片转成 file 上传(这里根据后端协商,此处后端要求)
- 将图片生成 PDF,并点击批量下载
实现步骤
canvas 文字排版
在一般 HTML
容器中,如果要实现文字的排版很容易。比如:
实现 文本超出自动换行 ,默认文本超出容器宽度就会自动换行,也可以使用word-wrap:break-word
实现强制换行。
实现 文字竖排, 有几种方式:
- 给文本容器设置
writing-mode
样式:(存在兼容性问题)
writing-mode:vertical-rl;// 垂直方向自右而左的书写方式。即 top-bottom-right-left
或者
writing-mode:vertical-lr;// 垂直方向内内容从上到下,水平方向从左到右
具体效果如图:
但是这个对于浏览器也存在一定兼容性问题,使用的时候需要注意。
- 使用
宽度
控制换行:(不存在兼容性,推荐方式)
设置每行的宽度为一个字大小,利用文本超出默认换行的特性,或者设置超出强制换行, 实现文本竖排。
- 利用
br
标签实现或者每个文字存放一个标签实现换行:(很死板的写法,比较 low,不推荐)
给每个文字后添加 br
标签,或者每个文字放一个标签,这样写灵活性不高,非常不推荐!
在 canvas
中实现文字排版
- 实现文字横排
canvas
中,如果文本超出 canvas
大小,并不会自动换行,会直接在超出的后面继续绘制成一排。canvas
中也没有直接可以设置换行的 api,那该怎么实现换行呢?
可以通过 js
控制,通过计算当前绘制文字的 x
坐标,如果 x 坐标大于 canvas
的宽度,将 x
坐标赋值为 0
(绘制的起始点 x 坐标),y
坐标累加一个文字的高度,从而实现文本换行。
- 实现文字竖排
竖排的逻辑和横排是一样的。文字竖排只是 y
坐标累加,趟超过 canvas
的高度时,将 y
坐标赋值为 0
(绘制的起始点 y 坐标),x
坐标累加一个文字的高度,从而实现竖排且文本换行。
部分代码片段
/**
* canvas 绘制文字
* @param {CanvasRenderingContext2D 对象} context
* @param {绘制内容} text
* @param {起始点 x 坐标制} x
* @param {起始点 y 坐标制} y
*/
drawTextVertical(context, text, x, y) {
let startX = x,
startY = y; // 记录开始的位置,用于文字换行赋值
let spaceCount = 0;
let arrText = text.trim().split('');
let formatText = text.replace(/\//g, '').split(''); // 去掉单斜杠
let align = context.textAlign;
let baseline = context.textBaseline;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.font = 'Pacifico'
// 开始逐字绘制
arrText.forEach(function (letter, index) {
// 确定下一个字符的纵坐标位置
// 是否需要旋转判断
let code = letter.charCodeAt(0);
// 计算文字间距
let letterWidth = 22 * 2.3;
if (code <= 256) {context.translate(x, y);
// 英文字符,旋转 90°
context.rotate(90 * Math.PI / 180);
context.translate(-x, -y);
}
if (code !== 47) context.fillText(letter, x, y);
// 旋转坐标系还原成初始态
context.setTransform(1, 0, 0, 1, 0, 0);
// 单斜杠换行或者长度超过 8 此处要过滤在第 9 字是换行的符号的情况
if ((code === 47 && !spaceCount) || (!spaceCount && index && index % 7 === 0)) {
// 单斜杠 / 代表换行 charCode=47
spaceCount += 1;
y = startY;
x = index ? (startX + letterWidth) : x;
startX = x;
} else if (code !== 47) {
// 如果是空格 减少字间距
if (code !== 32) {y = y + letterWidth;} else {y = y + letterWidth / 2}
}
});
// 水平垂直对齐方式还原
context.textAlign = align;
context.textBaseline = baseline;
}
canvas 设置背景颜色
canvas
生成图片的时候可以指定图片格式 (jpg,jpeg,png 等), 但是只能生成 位图
(放大会失真)。如果想提高canvas
生成图片的质量,可以引入 hidpi-canvas-polyfill 插件,具体使用可以参考这篇文章 解决 canvas 生成图片模糊。canvas
生成图片的背景默认是透明的,如果想单独设置背景颜色,可以使用 ctx.fillStyle
进行填充,但是设置文字颜色,则文字颜色会覆盖背景颜色,因为设置文字颜色也是使用 ctx.fillStyle
。那么,这种情况可以使用一下办法解决:
1. 使用canvas.getImageData
复制画布上的像素数据
2. 循环遍历复制的每个像素点,然后给每个像素设置rgb
值
3. 将设置好的流数据通过 putImageData
放回画布上。
let imageData = ctx.getImageData(0, 0, width, height);
for (let i = 0; i < imageData.data.length; i += 4) {
// 当该像素是透明的, 则设置成白色
if (imageData.data[i + 3] == 0) {imageData.data[i] = 255;
imageData.data[i + 1] = 255;
imageData.data[i + 2] = 255;
imageData.data[i + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
但是要注意背景和文字的绘制顺序,必须先绘制背景,再绘制文字
,如果顺序颠倒,则文字会出现很明显的锯齿状,有点模糊, 这就和定位中z-index
原理类似。
canvas 引入外部字体
1. 首先引入字体库,为了节省本地空间,可以从服务端引入, 但是需要 注意跨域问题 ,
也可以将字体库放本地,直接相对路径引入。
// 从服务端引入
@font-face {
font-family: "FZCUJINLJW";
src: url('https://www.xxxx.com/FZCUJINLJW.TTF') ;
}
// 本地引入
@font-face {
font-family: "FZCUJINLJW";
src: url('../../assets/FZCUJINLJW.TTF') ;
}
2. 通过 CanvasRenderingContext2D
对象设置字体,字号等
ctx.font = '24px FZCUJINLJW';
ctx.fillStyle = '#db9a00';// 填充颜色
canvas 绘制文字图片
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');// 拿到一个 CanvasRenderingContext2D 对象
ctx.beginPath();// 开始绘制文字
ctx.font = `${FONT_SIZE}px FZCUJINLJW`;
ctx.fillStyle = '#db9a00';// 填充颜色
ctx.fillText('绘制的内容', /* 绘制的 x 坐标 */, /* 绘制的 y 坐标 */);
let imgBase64 = canvas.toDataURL('image/png', 1);
ctx.closePath();
ctx.save();// 保存当前画布内容
// 如果需要在画布上循环绘制多次,需要手动清除画布上已经保存的内容,如果不清除,则画布内容会叠加。ctx.clearRect(0, 0, canvasObj.width, canvasObj.height);
canvas 生成图片并上传服务端
通过 ctx.toDataURL
可以获取到画布内容的base64 编码
let imgBase64 = canvas.toDataURL('image/png', 1);
如果服务端支持使用 base64
上传,则不用处理,此处因为后端需要 file 文件类型,所以需要将 base64
转成file 对象
,代码如下:
let file = dataURLtoFile(imgBase64, 'jpg'); // 将 base 转为 file 对象
function dataURLtoFile(urlData, fileName) {var bytes = window.atob(urlData.split(',')[1]); // 去掉 url 的头,并转换为 byte
var mime = urlData.split(',')[0].match(/:(.*?);/)[1];
// 处理异常, 将 ascii 码小于 0 的转换为大于 0
var ab = new ArrayBuffer(bytes.length);
var ia = new Uint8Array(ab);
for (var i = 0; i < bytes.length; i++) {ia[i] = bytes.charCodeAt(i);
}
return new File([ab], fileName, {type: mime});
}
转成 file 对象
后,通过 FormData
格式上传
let formdata = new FormData();
formdata.append('multipartList', file);
ajax.post(url,data:formdata).then()
此处需要注意,当 canvas
生成的图片比较小时(比如 5kb 以下),有可能导致文件上传失败,我之前踩过此坑。
将图片生成 PDF,并点击批量下载
此处是和后端商量,将 canvas
生成的图片上传服务端,并返回图片的 OSS
地址,再将此地址作为参数传给后端,获取到 PDF
的下载链接,前端通过 window.open(url)
的方式实现文件下载。
let uploadUrl = window.interfercesPrefix + '/admin/goods/tbgoods/uploadImages';
let downLoadUrl = '/app/goods/tbgoods/downLoadPdf';
// 上传图片
ajaxUploderImg({url: uploadUrl, data: formdata}).then(res => {
// 将图片作为参数获取 PDF 下载地址
this.props.dispatch(downLoadPdf({ url: downLoadUrl, imgUrl: res.data}));
}).catch(err => {if (err) {notification['error']({
message: err.message,
description:
'图片绘制出错,请重试!',
});
} else {notification['error']({message: '下载出错,请返回'});
}
})