小程序生产海报的些许思考与画图方法的实现

875 阅读4分钟

写在前面

功能需求完成一段时间了。特此记录一下。

需求

就是生成一张海报

想法

通过ScrollView作为外壳,将所需的节点信息写在Scroll内。通过小程序的API来获取滚动容器内部滚动高度。以达到生成长图的目的。

简单的说,就是将ScrollView作为一个容器,Canvas根据屏幕的DPI来确定保证生成图片清晰

  1. 初始时使用Canvas 2D的方式生成图片。发现生成图片的最大高度不能超过4096不满足产品生成长图的需求故放弃
  2. 图片模糊问题。由于用户上传的图片大小不一致。所以动态计算canvas的大小
  3. 尽可能的优化图片的大小。画图所需要的路径必须调用getImageInfo 来获取。本地临时路径会占用大量的内存空间
  4. 画布画完需要调用save 函数,虽然文档未说明。但是不调用save 函数会导致背景图变为透明。画图存在异常
  5. 小程序读取本地文件会将png格式的图片转变给jp透明图片会添加白色背景,解决方案:使用webView,H5原生读取文件。绕过小程序读取文件。
  6. Android系统画图片不能删除本地图片缓存。不然画不上,可以做数组收集缓存路径,在生成图片保存后遍历删除。ios 系统可以先画画布上,删除缓存也没有问题。
  7. 小程序部分Android系统中如果画图存在小数点,会导致画图异常,具体表现为奇形怪状的不规则图形。

方法与思路

第一步 确定ScrollView容器的具体大小

/**
 * params :
 *         el : 元素节点选择器使用
 *         $scope: this指向,元素所在的上下文,
 * */
 
// 获取滚动视图的节点信息

export const getScrollViewInfo = ({ el, $scope }) => {
  return new Promise(resolve => {
    if (el) {
      const query = Taro.createSelectorQuery().in($scope)
      query
        .select(el)
        .fields({ scrollOffset: true }, res => {
          resolve(res)
        })
        .exec()
    } else {
      resolve({ scrollHeight: 0, scrollLeft: 0, scrollTop: 0, scrollWidth: 0 })
    }
  })
}

第二步 确定真实Canvas 画图的尺寸

//  CANVAS_SCALE_MULTIPLE 为常量。根据屏幕的DPI来确定 

/**
 * params :
 *         width : 宽度 需要画的 容器宽度 即第一步获取的ScrollView的宽度
 *         height : 高度 需要画的 容器高度 即第一步获取的ScrollView的高度
 * */
// 计算画布尺寸
export const computeCanvasSize = ({ width, height }) => {
  return new Promise(resolve => {
    const _width = width * CANVAS_SCALE_MULTIPLE
    const _height = height * CANVAS_SCALE_MULTIPLE
    resolve({
      canvasWidth: _width,
      canvasHeight: _height
    })
  })
}

第三步 将ScrollView内的元素画在Canvas上

// 获取选择器的元素信息

 // 由于Canvas 和真实的ScrollView 的比例为CANVAS_SCALE_MULTIPLE 所以画图的时间要将所有的元素位置放大 CANVAS_SCALE_MULTIPLE倍 画在Canvas 上
 
 //具体所有方法代码如下:
 
 // 工具方法
 // 获取选择器的元素信息  
export const getElementInfo = ({ el, $scope }) => {
  return new Promise(resolve => {
    if (el) {
      const query = Taro.createSelectorQuery().in($scope)
      query
        .selectAll(el)
        .boundingClientRect(rect => {
          resolve(rect)
        })
        .exec()
    } else {
      resolve([{ left: 0, top: 0, width: 0, height: 0 }])

方法 截取Canvas内部的一段区域进行画图


 // 方法 截取Canvas内部的一段区域进行画图
export function drawClipRect(imageWidth, imageHeight, wrapWidth, wrapHeight) {
  let resultLeft, resultTop, resultWidth, resultHeight

  resultHeight = Math.floor(imageWidth * (wrapHeight / wrapWidth))
  if (resultHeight > imageHeight) {
    resultHeight = imageHeight
    resultWidth = Math.floor(resultHeight * (wrapWidth / wrapHeight))
    resultLeft = (imageWidth - resultWidth) / 2
    resultTop = 0
  } else {
    resultLeft = 0
    resultTop = (imageHeight - resultHeight) / 2
    resultWidth = imageWidth
  }

  const result = {
    left: resultLeft,
    top: resultTop,
    width: resultWidth,
    height: resultHeight
  }
  return result
}

画ScrollView的背景即画布的背景

/**
 * params :
 *         ctx : 画布,
 *         canvasWidth :  画布的 宽度,
 *         canvasHeight: 画布的 高度,
 *         bgColor:  背景颜色
 * */

export const drawPosterBg = ({ ctx, canvasWidth, canvasHeight, bgColor = '#ffffff' }) => {
  return new Promise(resolve => {
    ctx.fillStyle = bgColor
    ctx.fillRect(0, 0, canvasWidth, canvasHeight) // 填充整个画布
    ctx.restore()
    ctx.save()
    resolve()
  })
}

小坑——ctx.arcTo

ctx.arcTo在安卓手机上画圆角会不兼容。因此改用ctx.arc方法

画单张图片

/**
 * params :
 *         ctx : 画布,
 *         photoInfo : 图片信息
 *         $scope: this指向,元素所在的上下文,
 *         rectInfo:{ } 对象。需要画的 元素的位置
 *                 {bottom: 317,dataset: {},height: 218,id: "",left: 24,right: 351,top: 99,width: 327}
 *         offsetLeft: 左边修正偏移量       0
 *         offsetTop: 顶部修正偏移量       0
 *         scrollTop: 滚动容器顶部距离   0
 *         circle: 画圆              false
 *         borderRadius: 画圆角      一个数字 或者 一个数组 [left_top,right_top,right_bottom,left_bottom] 从左上角开始的逆时针4个角度 和css 圆角一致
 *         borderWidth: 边框宽度      1
 *         borderColor : 边框颜色   transparent,
 *         cropType: 裁剪类型  居中裁剪center
 * */
// 画海报图片
export const drawPosterImage = async (config, $scope) => {
  const {
    ctx,
    offsetTop = 0,
    offsetLeft = 0,
    scrollTop = 0,
    rectInfo,
    photoInfo,
    circle = false,
    borderRadius = 0,
    borderWidth = 1,
    borderColor = 'transparent',
    cropType = ''
  } = config

  return new Promise(async resolve => {
    if (photoInfo.path) {
      // 容器的节点信息
      const { left, top, width, height } = rectInfo
      // 左边修正
      let _left = left - offsetLeft
      // 顶部修正
      let _top = top + scrollTop - offsetTop

      // 获取图片信息
      const img = await Taro.getImageInfo({
        src: photoInfo.path
      })

      if (borderColor) {
        ctx.setStrokeStyle(borderColor)
      } else {
        ctx.setStrokeStyle('transparent')
      }

      if (borderWidth) {
        ctx.setLineWidth(borderWidth)
      }

      // 画圆
      if (circle) {
        const radius = width / 2 //圆的半径
        ctx.save()
        ctx.beginPath()
        ctx.arc(
          (_left + radius) * CANVAS_SCALE_MULTIPLE,
          (_top + radius) * CANVAS_SCALE_MULTIPLE,
          radius * CANVAS_SCALE_MULTIPLE,
          0,
          Math.PI * 2,
          false
        )
        ctx.clip()
      }
      //画圆角

      if (borderRadius) {
        let _copyRadiusArr = null
        // 判断传过来是否是数组。数组的话 自定义圆角
        if (isArray(borderRadius)) {
          _copyRadiusArr = borderRadius
        } else {
          _copyRadiusArr = new Array(4).fill(borderRadius)
        }
        const [left_top, right_top, right_bottom, left_bottom] = _copyRadiusArr

        ctx.save()
        ctx.translate(_left * CANVAS_SCALE_MULTIPLE, _top * CANVAS_SCALE_MULTIPLE)
        ctx.beginPath()
        const resultWidth = width * CANVAS_SCALE_MULTIPLE
        const resultHeight = height * CANVAS_SCALE_MULTIPLE
        const _right_bottom_radius = right_bottom * CANVAS_SCALE_MULTIPLE
        const _left_bottom_radius = left_bottom * CANVAS_SCALE_MULTIPLE
        const _left_top_radius = left_top * CANVAS_SCALE_MULTIPLE
        const _right_top_radius = right_top * CANVAS_SCALE_MULTIPLE

        //从右下角顺时针绘制,弧度从0到1/2PI
        ctx.arc(
          resultWidth - _right_bottom_radius,
          resultHeight - _right_bottom_radius,
          _right_bottom_radius,
          0,
          Math.PI / 2
        )
        //矩形下边线
        ctx.lineTo(_right_bottom_radius, resultHeight)
        //左下角圆弧,弧度从1/2PI到PI
        ctx.arc(
          _left_bottom_radius,
          resultHeight - _left_bottom_radius,
          _left_bottom_radius,
          Math.PI / 2,
          Math.PI
        )
        //矩形左边线
        ctx.lineTo(0, _left_bottom_radius)
        //左上角圆弧,弧度从PI到3/2PI
        ctx.arc(_left_top_radius, _left_top_radius, _left_top_radius, Math.PI, (Math.PI * 3) / 2)
        //上边线
        ctx.lineTo(resultWidth - _left_top_radius, 0)
        //右上角圆弧
        ctx.arc(
          resultWidth - _right_top_radius,
          _right_top_radius,
          _right_top_radius,
          (Math.PI * 3) / 2,
          Math.PI * 2
        )
        //右边线
        ctx.lineTo(resultWidth, resultHeight - _right_top_radius)
        ctx.closePath()
        ctx.clip()
        _left = 0
        _top = 0
      }
      let cropLeft = 0
      let cropTop = 0
      let cropWidth = img.width
      let cropHeight = img.height

      if (cropType == 'center') {
        const result = drawClipRect(img.width, img.height, width, height)
        cropLeft = result.left
        cropTop = result.top
        cropWidth = result.width
        cropHeight = result.height
      }

      await ctx.drawImage(
        img.path,
        cropLeft,
        cropTop,
        Math.floor(cropWidth),
        Math.floor(cropHeight),
        Math.floor(_left * CANVAS_SCALE_MULTIPLE),
        Math.floor(_top * CANVAS_SCALE_MULTIPLE),
        Math.floor(width * CANVAS_SCALE_MULTIPLE),
        Math.floor(height * CANVAS_SCALE_MULTIPLE)
      )
      if (borderWidth) {
        ctx.stroke()
      }

      ctx.restore()
      // 延时画下个图片
      cacheFilePath.push(img.path)
      setTimeout(() => {
        resolve()
      }, 30)
    }
  })
}}
  })
}

小坑——emoji

由于emoji表情是4个字节储存。ctx.measureText在计算宽度时可能会把emoji表情拆开导致绘emoji表情失败,所以遍历字符串方法上 用了for of 循环

获取需要画的文字数组

/**
 * @params
 * ctx  指定的画布,
 * text 要处理的文字
 * limitWidth   限制宽度
 * fontSize 文字大小
 * fontFamily   字体
 *  maxRows 最大几行
 *
 * */
// 获取需要画的文字数组
export const getCanvasWrapTextList = async config => {
  return new Promise(resolve => {
    const { ctx, text, limitWidth, maxRows } = config
    if (!text) return []
    let textList = []
    let computedText = ''

    for (let str of text) {
      if (ctx.measureText(computedText).width < limitWidth * CANVAS_SCALE_MULTIPLE) {
        computedText += str
      } else {
        textList.push(computedText)
        computedText = str
      }
    }
    textList.push(computedText)

    if (maxRows < textList.length && maxRows >= 1) {
      const resultText = textList[maxRows - 1]
      if (resultText.length > 1) {
        textList[maxRows - 1] = resultText.slice(0, resultText.length - 1) + '...'
      }
    }
    resolve(textList.slice(0, maxRows))
  })
}

小坑——ctx.setTextAlign

ctx.setTextAlign设置居中方法不成功所以可以自己手动设置

画文字


export const drawCanvasPosterText = async config => {
  const {
    ctx,
    text,
    offsetTop = 0,
    offsetLeft = 0,
    scrollTop = 0,
    rectInfo,
    color = '#000',
    fontFamily,
    fontSize = 12,
    textAlign = 'left',
    textBaseLine = 'middle',
    lineHeight,
    maxRows = 1
  } = config
  if (text) {
    // 容器的节点信息
    const { left, top, width } = rectInfo
    // 左边修正
    const _left = left - offsetLeft
    // 顶部修正
    const _top = top + scrollTop - offsetTop
    // 画图水平方向修正
    let _drawLeft = 0
    // 画图垂直方向修正
    let _drawTop = 0

    // 设置颜色
    ctx.setFillStyle(color)
    // 设置文字字号
    ctx.setFontSize(fontSize * CANVAS_SCALE_MULTIPLE)

    // 设置文字水平方式
    ctx.setTextAlign(textAlign)
    if (textAlign === 'center') {
      _drawLeft = _left + width / 2
    } else if (textAlign === 'right') {
      _drawLeft = _left + width
    } else {
      _drawLeft = _left
    }

    _drawLeft = Math.floor(_drawLeft)
    // 设置文字垂直方式
    ctx.setTextBaseline(textBaseLine)

    //  const { ctx, text, limitWidth, maxRows } = config
    const textList = await getCanvasWrapTextList({
      ctx,
      text,
      limitWidth: width,
      maxRows
    })
    let _lineHeight = 0
    if (lineHeight) {
      _lineHeight = lineHeight
    } else {
      _lineHeight = fontSize * 1.5
    }

    for (let j = 0; j < textList.length; j++) {
      _drawTop = _top + (j + 0.5) * _lineHeight
      ctx.fillText(textList[j], _drawLeft * CANVAS_SCALE_MULTIPLE, _drawTop * CANVAS_SCALE_MULTIPLE)
    }
  }
}

整合函数。为了方便画图片,相同class类的图片直接调用该方法


/**
 * params :
 *         ctx : 画布,
 *         canvas:
 *         scrollTop,
 *         photo,  接收一个数组或一个图片路径  [{
 *           path:"",
 *           width:"",
 *           height,
 *         }]  或者 "path"
 * return:
 *     整合函数。为了方便画图片
 *
 * */
export const exportDrawImage = async config => {
  const { el, $scope, photo, ...rest } = config || {}
  const elementArr = await getElementInfo({
    el,
    $scope
  })
  let _photo = null
  if (isArray(photo)) {
    _photo = photo
  } else {
    _photo = new Array(elementArr.length).fill({
      path: photo
    })
  }
  try {
    for (let i = 0; i < elementArr.length; i++) {
      await drawPosterImage(
        {
          rectInfo: elementArr[i],
          photoInfo: _photo[i],
          ...rest
        },
        $scope
      )
    }
  } catch (e) {
    console.log(e)
  }
}

整合函数。为了方便画文字


/**
 * params :
 *         ctx : 画布,
 *         canvas:
 *         scrollTop,
 *         texts,
 * return:
 *     整合函数。为了方便画文字
 *
 * */
export const exportDrawText = async config => {
  const { el, $scope, text, ...rest } = config || {}
  const elementArr = await getElementInfo({
    el,
    $scope
  })
  let _texts = null
  if (isArray(text)) {
    _texts = text
  } else {
    _texts = [text]
  }
  try {
    for (let i = 0; i < elementArr.length; i++) {
      await drawCanvasPosterText(
        {
          rectInfo: elementArr[i],
          text: _texts[i],
          ...rest
        },
        $scope
      )
    }
  } catch (e) {
    console.log(e)
  }
}

整合 方法 在图片上开辟出来新的空间

export const exportDrawView = async config => {
  const { el, $scope, ...rest } = config || {}
  const elementArr = await getElementInfo({
    el,
    $scope
  })
  try {
    for (let i = 0; i < elementArr.length; i++) {
      await drawCanvasPosterView(
        {
          rectInfo: elementArr[i],
          ...rest
        },
        $scope
      )
    }
  } catch (e) {
    console.log(e)
  }
}

画圆角矩形

export async function drawCanvasView({ ctx, width, height, radius }) {
  ctx.beginPath()
  //从右下角顺时针绘制,弧度从0到1/2PI
  ctx.arc(width - radius, height - radius, radius, 0, Math.PI / 2)

  //矩形下边线
  ctx.lineTo(radius, height)

  //左下角圆弧,弧度从1/2PI到PI
  ctx.arc(radius, height - radius, radius, Math.PI / 2, Math.PI)

  //矩形左边线
  ctx.lineTo(0, radius)

  //左上角圆弧,弧度从PI到3/2PI
  ctx.arc(radius, radius, radius, Math.PI, (Math.PI * 3) / 2)

  //上边线
  ctx.lineTo(width - radius, 0)

  //右上角圆弧
  ctx.arc(width - radius, radius, radius, (Math.PI * 3) / 2, Math.PI * 2)

  //右边线
  ctx.lineTo(width, height - radius)
  ctx.closePath()
}

绘制圆角矩形的各个边

export const drawCanvasPosterView = async config => {
  //圆的直径必然要小于矩形的宽高
  const {
    rectInfo,
    radius = 0,
    scrollTop,
    offsetTop,
    offsetLeft,
    ctx,
    fillColor,
    borderColor,
    borderWidth
  } = config
  // 容器的节点信息
  const { left, top, width, height } = rectInfo

  if (2 * radius > width || 2 * radius > height) {
    return false
  }

  // 左边修正
  const _left = left - offsetLeft
  // 顶部修正
  const _top = top + scrollTop - offsetTop

  ctx.save()
  if (borderColor) {
    ctx.setStrokeStyle(borderColor)
  } else {
    ctx.setStrokeStyle('transparent')
  }

  if (borderWidth) {
    ctx.setLineWidth(borderWidth)
  }
  ctx.translate(_left * CANVAS_SCALE_MULTIPLE, _top * CANVAS_SCALE_MULTIPLE)
  //绘制圆角矩形的各个边
  await drawCanvasView({
    ctx,
    width: width * CANVAS_SCALE_MULTIPLE,
    height: height * CANVAS_SCALE_MULTIPLE,
    radius: radius * CANVAS_SCALE_MULTIPLE
  })
  if (fillColor) {
    ctx.fillStyle = fillColor
    ctx.fill()
  }

  if (borderWidth) {
    ctx.stroke()
  }

  ctx.restore()
}

第四步 将画好的Canvas保存成临时路径

/**
 *   @params:
 *         x:  指定的画布区域的左上角横坐标
 *         y :  指定的画布区域的左上角纵坐标,
 *         canvasWidth : 指定的画布区域的宽度
 *         canvasHeight:  指定的画布区域的高度
 *         destWidth: 输出的图片的宽度
 *         destHeight:输出的图片的高度
 *         customOutput   // Boolean  自定义输出  指定自定义输出时 务必指定输出高度
 *         默认输出为canvas 的整个画布,如需自定义 需设置
 *
 * */
// 将canvas生成本地临时图片
export const canvasToTempImageAndSave = async params => {
  const {
    $scope,
    width,
    height,
    destWidth = 750,
    destHeight = 0,
    customOutput,
    completeCb,
    isSaveAlbum = true,
    completeBlock,
    ...rest
  } = params

  let _destHeight = (destWidth * height) / width
  //如果自定义输出
  if (customOutput) {
    _destHeight = destHeight
  }
  Taro.canvasToTempFilePath(
    {
      x: 0,
      y: 0,
      width,
      height,
      destWidth,
      destHeight: _destHeight,
      canvasId: 'posterCanvas',
      ...rest,
      success: async res => {
        const filePath = res.tempFilePath
        console.log(filePath)
        if (!isSaveAlbum) {
          if (completeBlock) {
            console.log(filePath)
            completeBlock({
              tempFilePath: filePath
            })
          }
          return
        }

        console.log(filePath)
        const fileSize = await getTempImageSize({
          filePath
        })
        if (fileSize < 100 * 1024) {
          Taro.showToast({
            title: '绘制失败请重新绘制',
            icon: 'none'
          })
          return false
        }
        saveTempImageInAlbum({
          filePath: res.tempFilePath,
          completeCb,
          ...rest
        })
      },
      fail: res => {
        Taro.showToast({
          title: '生成照片失败',
          icon: 'none'
        })
        console.log(res)
        if (completeBlock) {
          completeBlock({
            tempFilePath: ''
          })
        }
        completeCb && completeCb()
        //   todo 保存到相册失败函数
      },
      complete: res => {
        console.log(res)
      }
    },
    $scope
  )
}

第五步 将本地临时路径相片保存到相册

// 将本地临时路径相片保存到相册
export const saveTempImageInAlbum = params => {
  const { filePath, success, fail, completeCb } = params
  cacheFilePath.push(filePath)
  Taro.saveImageToPhotosAlbum({
    filePath,
    success: () => {
      // 保存成功提示
      Taro.showToast({
        title: '保存图片成功,请到相册查看',
        icon: 'none'
      })

      success && success()
    },
    fail: err => {
      fail && fail()
      if (err.errMsg === 'saveImageToPhotosAlbum:fail auth deny') {
        Taro.showModal({
          title: '提示',
          content: '需要获取访问相册权限',
          showCancel: false,
          success: resp => {
            if (resp.confirm) {
              Taro.openSetting({
                success: setting => {
                  if (setting.authSetting['scope.writePhotosAlbum']) {
                    Taro.showToast({
                      title: '授权成功,再次点击可保存图片',
                      icon: 'none'
                    })
                  } else {
                    Taro.showToast({
                      title: '授权失败,请重新操作',
                      icon: 'none'
                    })
                  }
                }
              })
            }
          }
        })
      }
    },
    complete() {
      // 延时50ms 画下个图片。预防图片画失败
      setTimeout(() => {
        completeCb && completeCb()
      }, 50)
      delTempImageArr({
        filePathArr: cacheFilePath
      })
    }
  })
}

优化项

由于生成Canvas画图片必须要生成临时路径。对性能较差和内存较小的手机不友好故做了临时文件删除策略

小坑:对于安卓系统不能将图片画到Canvas上就删除本地的缓存路径的,不然会保存到本地会失败。是一张空白图。故要在第五步后删除。

/**
 * @params
 *       filePath: 本地图片临时路径
 * */
// 删除本地临时路径
export const delTempImageArr = ({ filePathArr }) => {
  const fileMgr = Taro.getFileSystemManager()
  for (let i = 0; i < filePathArr.length; i++) {
    console.log('删除的临时路径', filePathArr[i])
    fileMgr.unlink({
      filePath: filePathArr[i]
    })
  }
}