likes
comments
collection
share

生成动态日签图-前端实现和后端实现

作者站长头像
站长
· 阅读数 13

需求讲解

根据业务的需求,需要把一些动态信息(人名、时间等)和图片资源生成一张图片,使用户能够保存到本地使用、或分享(不能粗暴的让用户截图)。

经过了技术调研,决定使用 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的字体垂直排版
  • 异步引入图片的处理

这里不一一解释,先贴我做时查到的资料。

学习 HTML5 Canvas 这一篇文章就够了(入门不错,示例比较全)

Canvas API中文文档(张鑫旭大神维护的文档,很全)

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 安装方式可能需要科学上网,特别慢、所以采用本地编译的方式。

canvas 文档

按照文档系统安装了对应系统平台需要的前置依赖之后,执行 npm install --build-from-source canvas 本地编译

canvas 库与浏览器端接口的差异

总体来说 canvas 库提供的接口完爆浏览器端,不但处理了图片异步加载(loadImage)、还提供了同步注册外部字体(registerFont)、和将画布转换为二进制流并压缩图片的接口和配置(toBuffer),再看看前端实现过程踩过的坑,感觉十分舒适。