# 图片生成方案研究

# toDataURL

目前移动端浏览器对于 canvas 的支持非常好,而 canvas 可以通过 toDataURL 来转换成 base64 图片。

# js 库

html2canvas (opens new window) dom-to-image (opens new window)

不太建议使用这类 js 库,可能需要填很多坑

# 外链图片的处理

对外链图片的处理是最复杂的

canvas 绘图时不会有任何问题,但是调用 toDataURL 这个方法时,浏览器会报错。

# 跨域处理

我们可以设置 crossOrigin 为 anonymous 来允许跨域,浏览器会为这张图片的请求头附带 Origin 信息,告诉静态资源服务器,请在响应头中附带 Access-Control-Allow-Methods、Access-Control-Allow-Origin,以便浏览器放行。

给图片链接后追加时间戳 有些时候,设置了 crossOrigin 依然会报错,其实不是设置了没有作用,而是 cdn 缓存了服务器响应结果,这个结果往往是没有上述两个字段的。这个时候可以考虑给图片链接后追加时间戳,对于 cdn 来说,这是一个没有请求过的资源,因此它会从源服务器去拿数据。

var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
  // 在图片加载完成后绘图,避免空白和断断续续加载
  ctx.drawImage(img, 0, 0);
};
img.src = 'https://xxxx' + '?' + +new Date();

# 保存图片

a 标签有一个 download 属性,可以将指定的资源下载下来,但该方法只适用于 pc 端,移动端基本不支持(Safari 会打开一个 base64 的网页,而在微信中甚至不会有任何响应,更不用提众多的安卓机)。

既然不能在浏览器主动保存图片,我们只好另辟蹊径,经调研发现:现在绝大多数的移动端浏览器都支持长按图片唤起下拉菜单来保存,因此我们可以通过文案提示用户进行操作,但它的弊端是没有 API 来调用,也就是说只能提示用户自发地进行长按保存操作,而我们对于用户是否保存了图片是无感知的

# 生成带图标的小程序二维码方案

/**
 * @method 加载图片后根据指定尺寸实现图片压缩
 * @param {String} src 图片路径
 * @returns {Object} {canvas, ctx, image, width, height, wh_ratio, hw_ratio, src, compressByWidth: Function, compressByHeight: Function, drawBorderRadius: Function}
 * @example
 * let img = await loadImage('https://pub-cdn-test.2haohr.com/24d3391f856b4198893953aa442a3587')
 * // 根据指定宽度压缩图片
 * let dataUrl = await img.compressByWidth(100, 'image/png')
 */
const loadImage = ({ src, fillStyle = '#fff' } = {}) =>
  new Promise((resolve, reject) => {
    const image = new Image();
    image.setAttribute('crossOrigin', 'anonymous');
    image.onload = () => {
      const { width } = image;
      const { height } = image;
      const wh_ratio = image.width / image.height;
      const hw_ratio = image.height / image.width;
      const imageToDataUrl = (width, height, mimeType, quality) => {
        let canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(image, 0, 0, width, height);
        const dataUrl = canvas.toDataURL(mimeType, quality);
        canvas = undefined;
        return dataUrl;
      };
      const canvas = document.createElement('canvas');
      canvas.width = image.width;
      canvas.height = image.height;
      const ctx = canvas.getContext('2d');
      if (fillStyle) {
        ctx.fillStyle = fillStyle;
        ctx.fillRect(0, 0, image.width, image.height);
      }
      resolve({
        /**
         * 画布
         */
        canvas,
        /**
         * 画布上下文
         */
        ctx,
        /**
         * 图片的Image对象
         */
        image,
        /**
         * 图片宽度
         */
        width,
        /**
         * 图片高度
         */
        height,
        /**
         * 宽高比
         */
        wh_ratio,
        /**
         * 高宽比
         */
        hw_ratio,
        /**
         * 图片链接
         */
        src,
        /**
         * 等比压缩图片到指定宽度
         * @param {*} w 图片最终宽度
         * @param {*} mimeType 内容类型,默认image/jpeg
         * @param {*} quality 图片质量0~1,mimeType = image/jpeg 时有效
         * @returns DataURL
         */
        async compressByWidth(w, mimeType = 'image/jpeg', quality = 0.92) {
          if (!w) {
            return;
          }
          const props = {
            width: w,
            height: Math.ceil(w / wh_ratio),
          };
          const dataUrl = await imageToDataUrl(
            props.width,
            props.height,
            mimeType,
            quality
          );
          return dataUrl;
        },
        /**
         * 等比压缩图片到指定高度
         * @param {*} h 图片最终高度
         * @param {*} mimeType 内容类型,默认image/jpeg
         * @param {*} quality 图片质量0~1,mimeType = image/jpeg 时有效
         * @returns DataURL
         */
        async compressByHeight(h, mimeType = 'image/jpeg', quality = 0.92) {
          if (!h) {
            return;
          }
          const props = {
            width: Math.ceil(h / hw_ratio),
            height: h,
          };
          const dataUrl = await imageToDataUrl(
            props.width,
            props.height,
            mimeType,
            quality
          );
          return dataUrl;
        },
        /**
         * 将图片处理成圆角
         */
        drawBorderRadius({
          x = 0,
          y = 0,
          w = width,
          h = height,
          r = 0,
          fillStyle = '',
        } = {}) {
          ctx.save(); // 保存当前 Canvas 画布状态
          ctx.beginPath();
          // 左上角
          ctx.arc(x + r, y + r, r, Math.PI, 1.5 * Math.PI);
          ctx.moveTo(x + r, y);
          ctx.lineTo(x + w - r, y);
          ctx.lineTo(x + w, y + r);
          // 右上角
          ctx.arc(x + w - r, y + r, r, 1.5 * Math.PI, 2 * Math.PI);
          ctx.lineTo(x + w, y + h - r);
          ctx.lineTo(x + w - r, y + h);
          // 右下角
          ctx.arc(x + w - r, y + h - r, r, 0, 0.5 * Math.PI);
          ctx.lineTo(x + r, y + h);
          ctx.lineTo(x, y + h - r);
          // 左下角
          ctx.arc(x + r, y + h - r, r, 0.5 * Math.PI, Math.PI);
          ctx.lineTo(x, y + r);
          ctx.lineTo(x + r, y);

          if (fillStyle) {
            ctx.fillStyle = fillStyle;
          }
          ctx.fill();
          ctx.closePath();

          ctx.clip();
          ctx.drawImage(image, x, y, w, h);
        },
      });
    };
    image.onerror = () => {
      reject();
    };
    image.src = src;
  });

/**
 * 将Logo图像画到指定画布上
 * @param {Object} ctx 画布上下文
 * @param {*} img logo图像
 * @param {*} x 横坐标
 * @param {*} y 纵坐标
 * @param {*} r 半径
 */
const drawLogo = (ctx, img, x, y, r) => {
  ctx.save();
  const d = r * 2;
  const cx = x + r;
  const cy = y + r;
  ctx.arc(cx, cy, r, 0, 2 * Math.PI);
  // 设置绘制圆形边框的颜色
  ctx.strokeStyle = '#fff';
  // 绘制出圆形,默认为黑色,可通过 ctx.strokeStyle = '#FFFFFF' 控制颜色
  ctx.stroke();
  ctx.clip();
  ctx.drawImage(img, x, y, d, d);
  // ctx.restore() // 恢复到保存时的状态
};

/**
 * 得到白色背景图片
 * @param {Number} width 宽度
 * @param {Number} height 高度
 * @returns 返回Image
 */
const getWhiteBgImage = async (width, height) => {
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fff';
  ctx.fillRect(0, 0, width, height);
  return loadImage({ src: canvas.toDataURL('image/png') });
};

/**
 * 合成带logo的小程序码
 * @param {Object} option 选项
 * @param {String} option.src 小程序码链接,可以是DataURL
 * @param {String} option.logoSrc logo链接,可以是DataURL
 * @returns 合成logo后的小程序码DataURL
 */
const composeLogo = async ({ src, logoSrc } = {}) => {
  if (!src) {
    return src;
  }
  if (!logoSrc) {
    return src;
  }
  // logo位置从图片的27.5%处开始
  // logo的宽度是图片的45%
  let [qrcode, logo] = await Promise.all([
    loadImage({ src }),
    loadImage({ src: logoSrc }),
  ]);
  const logoX = qrcode.width * 0.275;
  const logoW = qrcode.width * 0.45;
  const logoR = logoW / 2;

  // 画小程序码
  qrcode.ctx.drawImage(
    qrcode.image,
    0,
    0,
    qrcode.image.width,
    qrcode.image.height
  );

  if (logoSrc) {
    // 获取白底图像
    let whiteLogo = await getWhiteBgImage(logoW, logoW);
    // 等比压缩logo
    const logoDataUrl =
      logo.width >= logo.height
        ? await logo.compressByWidth(logoW, 'image/png')
        : await logo.compressByHeight(logoW, 'image/png');
    // 重新生成logo image
    logo = await loadImage({ src: logoDataUrl });
    // 将logo画到白底图像上
    whiteLogo.ctx.drawImage(
      logo.image,
      (logoW - logo.width) / 2,
      (logoW - logo.height) / 2,
      logo.width,
      logo.height
    );
    // 重新加载白底logo
    whiteLogo = await loadImage({
      src: whiteLogo.canvas.toDataURL('image/png'),
    });
    // 将白底logo合成到小程序码图像上
    drawLogo(qrcode.ctx, whiteLogo.image, logoX, logoX, logoR);
  }

  return qrcode.canvas.toDataURL('image/png');
};