用 Canvas 做了个证书生成器,纯前端的

前几天折腾了个小东西——深夜书虫俱乐部 · 阅读证书生成器

需求就一句话:用户填信息、上传头像,出来一张带印章的证书 PNG。有个硬性要求——不能有后端,不能传数据,必须纯前端搞定

在线体验:http://cert.limw.top

证书生成器


为什么没上框架?

说实话一开始也想用 React 的,但想了想这玩意儿就一个页面、三个步骤(填表 → 预览 → 下载),用框架有点杀鸡用牛刀。

最后用的原生 HTML + CSS + JS (ES5),整个项目就一个 index.html 文件。

好处很明显:

  • 部署就是拷贝文件,零构建
  • 没有 node_modules,加载飞快
  • 兼容性好,IE11 大概率也能跑(我没测过)

缺点也有,状态管理得自己搞,复杂点的东西还是得上框架。但这个场景够用了。


Canvas 图层合成的思路

这个项目最花时间的就是 Canvas 绘制部分。我把它拆成了 7 层,按顺序往上堆:

  1. 纸张纹理(渐变底色 + 噪点 + 暗角)
  2. 全屏水印(文字平铺旋转)
  3. 装饰边框(双线圆角矩形)
  4. 用户头像(圆形裁切)
  5. 印章图片
  6. 装饰图案(那个伪二维码)
  7. 文字信息

每层单独封装成一个函数,调试时注释掉某行就能排查问题,挺方便的。

先说纸张纹理

1function drawPaperLayer(ctx, W, H) { 2 // 渐变底色 3 var grad = ctx.createLinearGradient(0, 0, W, H); 4 grad.addColorStop(0, "#f6ecdb"); 5 grad.addColorStop(1, "#ecdcb8"); 6 ctx.fillStyle = grad; 7 ctx.fillRect(0, 0, W, H); 8 9 // 噪点——模拟纸张质感 10 ctx.save(); 11 ctx.globalAlpha = 0.05; 12 for (var i = 0; i < 1800; i++) { 13 ctx.fillStyle = Math.random() > 0.5 ? "#241a14" : "#ffffff"; 14 ctx.fillRect(Math.random() * W, Math.random() * H, 1, 1); 15 } 16 ctx.restore(); 17 18 // 暗角效果 19 var vign = ctx.createRadialGradient(W/2, H/2, H*0.25, W/2, H/2, H*0.85); 20 vign.addColorStop(0, "rgba(36,26,20,0)"); 21 vign.addColorStop(1, "rgba(36,26,20,0.16)"); 22 ctx.fillStyle = vign; 23 ctx.fillRect(0, 0, W, H); 24}

这里有几个坑:

  • 噪点数量我试了好多遍,800 太少看不出质感,1800 刚好,3000 以上开始影响性能
  • 暗角的半径参数也很讲究,太小没效果,太大整张纸都暗了,H*0.25H*0.85 这个范围是我试出来的

头像怎么裁成圆的

1function drawAvatarLayer(ctx, img, cx, cy, r) { 2 ctx.save(); 3 4 // 先画个圆形路径,然后 clip 5 ctx.beginPath(); 6 ctx.arc(cx, cy, r, 0, Math.PI * 2); 7 ctx.closePath(); 8 ctx.clip(); 9 10 // 等比缩放,类似 CSS 的 object-fit: cover 11 var iw = img.width, ih = img.height; 12 var scale = Math.max((r*2)/iw, (r*2)/ih); 13 var dw = iw*scale, dh = ih*scale; 14 ctx.drawImage(img, cx - dw/2, cy - dh/2, dw, dh); 15 16 ctx.restore(); // clip 结束 17 18 // 边框要在 restore 之后画,不然也会被裁掉 19 ctx.beginPath(); 20 ctx.arc(cx, cy, r, 0, Math.PI * 2); 21 ctx.lineWidth = 4; 22 ctx.strokeStyle = "#b3893c"; 23 ctx.stroke(); 24}

clip() 这个 API 很好用,先定义个路径形状,后面的绘制操作就只在这个区域内生效。边框一定要在 restore() 之后画,不然圆形边框也会被裁切掉——这个坑我踩了半小时才反应过来。

印章那点小细节

印章要是画得端端正正的反而假,真实盖章哪有那么准的:

1function drawStampLayer(ctx, stampImg, cx, cy, size) { 2 if (!stampImg) return false; 3 4 ctx.save(); 5 ctx.globalAlpha = 0.92; // 不透明度 92%,稍微透一点 6 ctx.translate(cx, cy); 7 ctx.rotate(-0.12); // 歪一点点,-6.8 度左右 8 9 var iw = stampImg.width, ih = stampImg.height; 10 var scale = size / Math.max(iw, ih); 11 var dw = iw * scale, dh = ih * scale; 12 ctx.drawImage(stampImg, -dw/2, -dh/2, dw, dh); // 以中心为锚点 13 ctx.restore(); 14 15 return true; 16}

旋转角度 -0.12 弧度是我调了好几个值选的,看起来比较自然。globalAlpha 设成 0.92 也是为了让印章跟底层文字有一点叠加感,不会显得太"浮"。

图片加载的异步问题

Canvas 绘制得等图片加载完才能开始,这里用了 Promise 来协调:

1function renderCertificateWhenReady() { 2 var avatarPromise = new Promise(function(resolve) { 3 if (state.avatarDataUrl) { 4 var img = new Image(); 5 img.onload = function() { resolve(img); }; 6 img.onerror = function() { resolve(null); }; // 加载失败也不能卡住 7 img.src = state.avatarDataUrl; 8 } else { 9 resolve(null); 10 } 11 }); 12 13 var stampPromise = new Promise(function(resolve) { 14 var img = new Image(); 15 img.onload = function() { resolve(img); }; 16 img.onerror = function() { resolve(null); }; 17 img.src = STAMP_SRC; 18 }); 19 20 Promise.all([avatarPromise, stampPromise]).then(function(results) { 21 renderCertificate(el.canvas, state, results[0], results[1]); 22 }); 23}

还有个容易忘的点——字体加载。Google Fonts 是异步的,如果字体没下完就开始画字,Canvas 会 fallback 到默认字体,排版全乱:

1if (document.fonts && document.fonts.ready) { 2 document.fonts.ready.then(doRender); 3} else { 4 doRender(); // 老浏览器降级处理 5}

我做了什么 / 还想做什么

已完成的

  • 在线地址:7 层 Canvas 合成的证书生成器 v1.0
  • 表单验证:中文姓名正则校验、必填项提示
  • deploy.sh:SCP 上传 + Nginx 配置一条龙
  • 移动端适配到 480px

接下来打算做的

目前只有"阅读证书"一种模板,后面想加几个:

  • 结业证书、荣誉证书、会员证书等不同样式
  • 让用户能拖拽调整各元素位置(现在位置都是写死的)
  • 用 localStorage 存一下历史记录,刷新不再丢失
  • 支持导入 Excel 批量生成,打包成 ZIP 下载

看心情排优先级吧。


为什么不做后端

这个项目的数据流非常简单:

用户输入 → 浏览器内存里存着
                ↓
        Canvas 合成出图片
                ↓
        用户下载到本地

姓名、照片这种敏感数据压根没离开过浏览器。不需要服务器、不需要数据库、不需要 API,Nginx 托个静态文件就行。

代价也有——刷新页面数据就没了(后续可以加 localStorage),也没法做真正的批量导出。但对于"演示用途"来说完全够用。


踩过的坑

问题怎么回事怎么解决的
高清屏上 Canvas 模糊没处理设备像素比 (DPR)canvas.width 设成显示尺寸 × DPR,CSS 固定显示尺寸
字体闪烁一下再变正常字体还没加载完就开始画了document.fonts.ready 再渲染
印章位置总是偏的drawImage 默认左上角对齐translate 到中心点,然后 -dw/2, -dh/2
iOS 上点下载没反应Safari 不支持 a 标签的 download 属性提示用户长按图片另存为

部署相关

Nginx 配置很简单,就是托管静态文件:

1server { 2 listen 80; 3 server_name cert.limw.top; 4 5 root /opt/certificate; 6 index index.html; 7 8 gzip on; # HTML 从 25KB 压到 6KB 9 10 # 图片缓存 30 天 11 location ~* \.(png|jpg|gif|ico)$ { 12 expires 30d; 13 add_header Cache-Control "public, immutable"; 14 } 15 16 # HTML 不缓存,每次都拉最新 17 location ~* \.html$ { 18 expires -1; 19 add_header Cache-Control "no-store, no-cache"; 20 } 21}

部署脚本就是 SCP 上传 + reload Nginx,几秒钟的事。


小结

这个项目不大,但把 Canvas 2D API 用得挺全的:渐变、裁切路径、变换矩阵、图片合成、异步资源协调……

如果你想做个类似的图片生成器,建议先把每一层的绘制逻辑拆成独立函数,按顺序调用。调试时注释掉某层代码就能快速定位问题,省很多事。

源码在 GitHub(链接待补),欢迎提 issue。