最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 小程序生产海报的些许思考与画图方法的实现

    正文概述 掘金(Loserkeys)   2021-03-27   531

    写在前面

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

    需求

    就是生成一张海报

    想法

    通过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]
        })
        cacheFilePath = []
      }
    }
    

    起源地下载网 » 小程序生产海报的些许思考与画图方法的实现

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元