记录一次小程序 canvas 生成长图

349 阅读6分钟

需求点

点某条打开日记的右上角,选择生成长图,到长图预览界面,可以切换背景图。 长图内容根据动态日记内容生成。

长图背景图原定,按比例缩放后在y轴上重复填充背景;后续考虑版权问题换成了背景给渐变色。 背景是图片的也会贴出来

效果图

picture.png

代码片段

// 保存图片
        saveImg() {
          wx.showLoading({
            title: '图片生成中',
          })
          this.createSelectorQuery()
            .select('#posterCanvas')
            .fields({
              node: true,
              size: true
            })
            .exec(this.drawPoster.bind(this));
        },
        // 获取图片信息
        getImgInfo(imgURL) {
          return new Promise((reslove) => {
            wx.getImageInfo({
              src: imgURL,
              success(res) {
                reslove(res);
              }
            })
          })
        },
        // 绘制海报
        async drawPoster(res) {
            // 获取到 Canvas 对象
            if (!res?.[0]?.node) {
                wx.hideLoading();
                return;
            }
            const canvas = res[0].node;
            // 渲染上下文
            const ctx = canvas.getContext('2d');
            // 获取设备像素比
            let winInfo = wx.getWindowInfo();
            let dpr = winInfo.pixelRatio
            const sysInfo = wx.getDeviceInfo();
            // 避免安卓机挂掉 安卓机dpr最大为2
            if (sysInfo.platform === 'android' && dpr > 2) {
                dpr = 2;
            }
            let dvwidth = winInfo.windowWidth;
            let dvheight = winInfo.windowHeight;

            // 准备要渲染的元素信息 === start
            let detail = this.data.dataSource; // 动态数据
            // 日期时间
            let createdTime_info = null;
            if (detail?.diary_info?.dateStr) {
                createdTime_info = this.getCreatedTimeRender(ctx, detail?.diary_info.dateStr, dvwidth);
            }
            // 星期几
            let daysOfWeek_info = null;
            if (detail?.diary_info?.daysOfWeek) {
                daysOfWeek_info = this.getDaysOfWeekRender(ctx, detail?.diary_info.daysOfWeek, dvwidth, createdTime_info);
            }
            // 训练营名字
            let campmissionname_info = null;
            if (detail?.camp_name) {
                campmissionname_info = this.getCampmissionNameRender(ctx, detail?.camp_name, dvwidth, createdTime_info);
            }
            // 第几次打开
            let diaryCount_info = null;
            // 渲染打卡次数时,该位置上面已绘制的最大y坐标
            let diaryCount_top_y = Math.max((campmissionname_info?.y || 0) + (campmissionname_info?.height || 0) - (campmissionname_info?.lineHeight || 0), daysOfWeek_info?.y || 0);
            if (detail?.diary_seq) {
                diaryCount_info = this.getDiaryCountRender(ctx, detail?.diary_seq, diaryCount_top_y);
            }
            // 日记标题
            let diaryTitle_info = null;
            let diaryTitle_top_y = Math.max(diaryCount_top_y || 0, (diaryCount_info?.y || 0) + (diaryCount_info?.height || 0) - (diaryCount_info?.lineHeight || 0));
            if (detail?.diary_info?.title) {
                diaryTitle_info = this.getDiaryTitleRender(ctx, detail?.diary_info?.title, dvwidth, diaryTitle_top_y);
            }
            // 日记内容
            let diaryContent_info = null;
            let diaryContent_top_y = Math.max(diaryTitle_top_y || 0, (diaryTitle_info?.y || 0) + (diaryTitle_info?.height || 0) - (diaryTitle_info?.lineHeight || 0));
            if (detail?.diary_info?.content) {
                diaryContent_info = this.getDiaryContentRender(ctx, detail?.diary_info?.content, dvwidth, diaryContent_top_y);
            }
            // 日记图片=== 九宫格展示,固定显示区域宽高,实现image的aspectFill模式
            let diaryImg_info = null;
            let diaryImg_top_y = Math.max(diaryContent_top_y || 0, (diaryContent_info?.y || 0) + (diaryContent_info?.height || 0) - (diaryContent_info?.lineHeight || 0));
            if (detail?.picture_url_list?.length) {
                diaryImg_info = await this.getDiaryImgRender(canvas, detail?.picture_url_list, dvwidth, diaryImg_top_y);
            }
            // 赞和评论
            let zanComment_top_y = Math.max(diaryImg_top_y || 0, (diaryImg_info?.y || 0) + (diaryImg_info?.height || 0));
            let zanComment_info = this.getZanCommentRender(ctx, canvas, {comment_list: detail?.comment_list, like_user_list: detail?.like_user_list}, dvwidth, zanComment_top_y);
            // 打卡人
             // 星期几
             let userName_info = null;
             let userNametop_y = Math.max(zanComment_top_y || 0, (zanComment_info?.y || 0) + (zanComment_info?.height || 0));
             if (detail?.user_name) {
                userName_info = this.getUserNameRender(ctx, '——' + detail?.user_name, dvwidth, userNametop_y);
             }
            // 准备要渲染的元素信息 === end
            
             // 初始化画布大小
             let calcHeight = userName_info?.y ? userName_info?.y * 1 + 30 : dvheight;
             calcHeight = Math.max(calcHeight, dvheight + 100);
             if (calcHeight * dpr < this.data.canvasMaxHeight) {
                //  ====高清 ===start
                 canvas.width = dvwidth * dpr;
                 canvas.height = calcHeight * dpr;
                 ctx.scale(dpr, dpr);
                //  ====高清 ===end
             } else {
                // 普通 --- start
                canvas.width = dvwidth;
                canvas.height = calcHeight;
                // 普通 --- end
             }
             // 清空画布
            ctx.clearRect(0, 0, canvas.width, canvas.height);
             // 绘制画布底色
            // const gradient = ctx.createRadialGradient(canvas.width/2, canvas.height/2, canvas.width/2, canvas.width/2, canvas.height/2, canvas.height/2);
            let gradient = ctx.createLinearGradient(0, 0, 0, calcHeight <= dvheight ? calcHeight: canvas.height);
            // 添加三个色标
            gradient.addColorStop(0, "white");
            gradient.addColorStop(0.2, this.data.curBgUrl);
            gradient.addColorStop(0.8, this.data.curBgUrl);
            gradient.addColorStop(1, "white");
            // 设置填充样式并绘制矩形
            ctx.fillStyle = gradient;
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            // 开始绘制
            //  第一步--绘制背景图
            //  await this.drawerBgImg(ctx, canvas, dvwidth)
             if (campmissionname_info) {
                 let fontInfo= {
                    fontColor: campmissionname_info?.fontColor,
                    fontSize: campmissionname_info?.fontSize
                 }
                this.drawerMutiText(ctx, campmissionname_info?.content, campmissionname_info?.x, campmissionname_info?.y, fontInfo, campmissionname_info?.lineHeight);
             }
             if (createdTime_info) {
                ctx.fillStyle = createdTime_info?.fontColor || '#333';
                ctx.font = `${createdTime_info?.fontSize} ${this.data.fontFamily}`;
                ctx.fillText(createdTime_info?.content, createdTime_info?.x, createdTime_info?.y);
             }
             if (daysOfWeek_info) {
                ctx.fillStyle = daysOfWeek_info?.fontColor || '#333';
                ctx.font = `${daysOfWeek_info?.fontSize} ${this.data.fontFamily}`;
                ctx.fillText(daysOfWeek_info?.content, daysOfWeek_info?.x, daysOfWeek_info?.y);
             }
             if (diaryCount_info) {
                ctx.fillStyle = diaryCount_info?.fontColor || '#333';

                ctx.font = `${diaryCount_info?.fontSize} ${this.data.fontFamily}`;
                ctx.fillText(diaryCount_info?.content1, diaryCount_info?.x1, diaryCount_info?.y);

                ctx.font = `bold ${diaryCount_info?.fontSize2} ${this.data.fontFamily}`;
                ctx.fillText(diaryCount_info?.content2, diaryCount_info?.x2, diaryCount_info?.y);
                
                ctx.font = `${diaryCount_info?.fontSize} ${this.data.fontFamily}`;
                ctx.fillText(diaryCount_info?.content3, diaryCount_info?.x3, diaryCount_info?.y);
             }
             if (diaryTitle_info) {
                let fontInfo= {
                   fontColor: diaryTitle_info?.fontColor,
                   fontSize: diaryTitle_info?.fontSize,
                   fontWeight: diaryTitle_info?.fontWeight
                }
               this.drawerMutiText(ctx, diaryTitle_info?.content, diaryTitle_info?.x, diaryTitle_info?.y, fontInfo, diaryTitle_info?.lineHeight);
             }
             if (diaryContent_info) {
                let fontInfo= {
                   fontColor: diaryContent_info?.fontColor,
                   fontSize: diaryContent_info?.fontSize
                }
               this.drawerMutiText(ctx, diaryContent_info?.content, diaryContent_info?.x, diaryContent_info?.y, fontInfo, diaryContent_info?.lineHeight);
             }
             if (diaryImg_info && diaryImg_info?.content) {
                let images1 = diaryImg_info?.content || [];;
                 // 所有图片都加载完毕,此时可以进行绘制操作
                 images1.forEach((image, index) => {
                    let dheight = diaryImg_info?.imgh || 0; // 单个图品固定高
                    let dwidth = diaryImg_info?.imgw || 0; // 单个图品固定宽
                    let ya = diaryImg_info?.y || 0;  // 在canvas上的y坐标
                    if (index >= 3 && index < 6) {
                        ya +=  dheight;
                    } else if (index >= 6 && index < 9) {
                        ya += dheight * 2;
                    }
                   let xa = (diaryImg_info?.x || 0) + (index % 3) * dwidth; // 在canvas上的x坐标
                   let originW = image.width;
                   let originH = image.height;
                   let scale = Math.max(dwidth / originW, dheight / originH);
                   let dw = dwidth / scale;
                   let dh = dheight / scale;
                   ctx.drawImage(image, (originW - dw) / 2,(originH - dh) / 2, dw, dw, xa, ya, dwidth, dheight);
                 })
             }
             if (zanComment_info) {
                let images2 = await Promise.all(zanComment_info?.icons);
                images2.forEach((image, index) => {
                   if (index == 0) {
                    ctx.drawImage(image, zanComment_info?.x1, zanComment_info?.y, zanComment_info?.iconsize, zanComment_info?.iconsize)
                   } else if (index == 1) {
                    ctx.drawImage(image, zanComment_info?.x3, zanComment_info?.y, zanComment_info?.iconsize, zanComment_info?.iconsize)
                   }
                })
                ctx.fillStyle = zanComment_info?.fontColor || '#333';
                ctx.font = `${zanComment_info?.fontSize} ${this.data.fontFamily}`;
                ctx.fillText(zanComment_info?.content2, zanComment_info?.x2, zanComment_info?.yTxt);
                ctx.fillText(zanComment_info?.content4, zanComment_info?.x4, zanComment_info?.yTxt);
             }
             if (userName_info) {
                ctx.fillStyle = userName_info?.fontColor || '#333';
                ctx.font = `${userName_info?.fontSize} ${this.data.fontFamily}`;
                ctx.fillText(userName_info?.content, userName_info?.x, userName_info?.y);
             }
             // 绘制完成
             setTimeout(()=>{
                this.canvasToImage(canvas);
             }, 500);
        },
        //   canvas生成图片
        canvasToImage(canvas) {
            let that = this;
            wx.canvasToTempFilePath({
                canvas,
                canvasId: 'posterCanvas',
                success: tplRes => {
                  wx.hideLoading();
                  if (tplRes.tempFilePath) {
                    wx.saveImageToPhotosAlbum({
                        filePath: tplRes.tempFilePath,
                        success(res) {
                            that.onCancel();
                            wx.showToast({
                                title: '已保存到相册',
                                icon: 'success',
                                duration: 3000
                            })
                        },
                        fail(error) {
                            // console.log('===asdas', error)
                        }
                    })
                  }
                },
                fail: error => {
                  console.log('==error=', error)
                }
            })
        },
        // 绘制背景图,按比例缩放后y轴重复
        async drawerBgImg(ctx, canvas, dvwidth) {
            // 解析活动背景图片
            let posterImgURL = this.data.curBgUrl;
            // 获取源图片宽高
            let posterImgInfo = await this.getImgInfo(posterImgURL)
            // 计算源图片的宽高比
            let posterImgRate = posterImgInfo.width / posterImgInfo.height
            // 计算出新的图片宽高
            let bgnewWidth = dvwidth;
            let bgnewHeight = bgnewWidth / posterImgRate;
            let repeatNCount = Math.ceil(canvas.height / bgnewHeight); //背景图重复次数,向上取整
            let bgPromiseList = [];
            for (let j = 0; j < repeatNCount; j++) {
                let prs = new Promise((resolve, reject) => {
                    let tempimg = canvas.createImage();
                    tempimg.src = posterImgURL;
                    tempimg.onload = function() {
                      resolve(tempimg)
                    }
                  })
                bgPromiseList.push(prs);
            }
            let bgimages = await Promise.all(bgPromiseList);
            // 所有图片都加载完毕,此时可以进行绘制操作
            bgimages.forEach((image, index) => {
                ctx.drawImage(image, 0, 0 + index * bgnewHeight, bgnewWidth, bgnewHeight)
            })
        },
        // 打卡创建日期
        getCreatedTimeRender(ctx, text, dvwidth) {
            let fontSize = '15px';
            let fontColor = '#333';
            ctx.fillStyle = fontColor;
            ctx.font = `${fontSize} ${this.data.fontFamily}`;
            let width = ctx.measureText(text).width || 0; // 文本自己宽度
            let obj = {
                x: dvwidth - this.data.canvasLrPadding - width,
                y: this.data.canvasTpPadding + 15, // 20--指字体大小是20px时文本大概高度
                content: text,
                width,
                height: 20,
                fontColor, // 后面改成根据背景色取
                fontSize
            }
            return obj;
        },
        // 打卡星期几
        getDaysOfWeekRender(ctx, text, dvwidth, createdTime_info) {
            let fontSize = '15px';
            let fontColor = '#333';
            ctx.fillStyle = fontColor;
            ctx.font = `${fontSize} ${this.data.fontFamily}`;
            let width = ctx.measureText(text).width || 0; // 文本自己宽度

            let height = 20; // 文本自己高度---不准确
            let createdTime_w = createdTime_info?.width || 0;
            let createdTime_y = createdTime_info?.y || 0;
            let obj = {
                x: dvwidth - this.data.canvasLrPadding - width - (createdTime_w - width) / 2,
                y: createdTime_y + 10 + height, // 20--指字日期和星期几上下间距
                content: text,
                width,
                height,
                fontColor, // 后面改成根据背景色取
                fontSize
            }
            return obj;
        },
        // 训练营名字
        getCampmissionNameRender(ctx, text, dvwidth, createdTime_info) {
          let rgtW = createdTime_info?.width || 0;
          let w = dvwidth - this.data.canvasLrPadding * 2 - rgtW - 25; // 20是训练营名字很多时-和日期之间水平间距
          let lineHeight = 25;
          let fontObj = {
            fontColor: '#333', // 后面改成根据背景色取
            fontSize: '20px',
            lineHeight,
          }
          let rows = this.getMutiTextRows(ctx, text, w, fontObj);
          let obj = {
              x: this.data.canvasLrPadding,
              y: this.data.canvasTpPadding + 15, // 20--指字体大小是20px时文本大概高度
              content: rows,
              width: w,
              height: rows.length * lineHeight,
              ...fontObj
          }
          return obj;
        },
        // 获取多行文本:切成行数组
        getMutiTextRows(ctx, text,w,fontObj, rownum) {
          if (!text) return [];
            //自动换行介绍
            let temp = '';
            let rows = [];
            let chartArr = text.split('');
            ctx.fillStyle = fontObj?.fontColor || '#333';
            ctx.font = `${fontObj?.fontWeight ? (fontObj?.fontWeight + ' ') : ''}${fontObj?.fontSize} ${this.data.fontFamily}`;
            for (var a = 0; a < chartArr.length; a++) {
              if (ctx.measureText(temp).width < w) {
              } else {
                rows.push(temp);
                temp = '';
              }
              temp += chartArr[a];
            }
            rows.push(temp);
            if (rownum && rows.length > rownum) {
              rows = rows.slice(0, rownum);
              let lastChart = rows[rownum - 1];
              rows[rownum - 1] = lastChart?.substring(0, lastChart - 2) + '...'
            }
            return rows
        },
        // 获取 第几次打卡的渲染信息
        getDiaryCountRender(ctx, count, hasy) {
          let x1 = this.data.canvasLrPadding;
          let fontSize = '18px';
          let fontSize2 = '28px';
          ctx.font = `${fontSize} ${this.data.fontFamily}`;
          let x1w = ctx.measureText('第').width;
          let x2 = x1 + x1w;
          ctx.font = `bold ${fontSize2} ${this.data.fontFamily}`;
          let x2w = ctx.measureText(count).width || 0;
          let x3 = x2 + x2w;
          ctx.font = `${fontSize} ${this.data.fontFamily}`;
          let x3w = ctx.measureText('次打卡').width;
          let width = x1w + x2w + x3w;
          let lineHeight = 20;
          return {
              x1,
              y: hasy + 20 + lineHeight, // 25---指的是:第几次打卡和上面最近一个元素的垂直间距
              x2,
              x3,
              width,
              height: lineHeight,
              lineHeight,
              content1: '第',
              content2: count,
              content3: '次打卡',
              fontColor: '#333', // 后面改成根据背景色取
              fontSize,
              fontSize2,
              fontWeight2: 'bold'
          }
        },
        // 日记标题
        getDiaryTitleRender(ctx, text, dvwidth, hasy) {
            let lineHeight = 25;
            let x = this.data.canvasLrPadding;
            let y  = hasy + 30; // 30-日记标题距离上面最近一个元素的垂直间距
            let w = dvwidth - this.data.canvasLrPadding * 2; // 20是训练营名字很多时-和日期之间水平间距
            let fontObj = {
                fontColor: '#333', // 后面改成根据背景色取
                fontSize: '20px',
                fontWeight: 'bold'
            }
            let rows = this.getMutiTextRows(ctx, text, w, fontObj);
            let height = rows.length * lineHeight;
            return {
                x,
                y,
                content: rows,
                width: w,
                height,
                lineHeight,
                ...fontObj
            }
        },
        // 日记内容
        getDiaryContentRender(ctx, text, dvwidth, hasy) {
            let w = dvwidth - this.data.canvasLrPadding * 2; // 20是训练营名字很多时-和日期之间水平间距
            let lineHeight = 20;
            let fontObj = {
                fontColor: '#333', // 后面改成根据背景色取
                fontSize: '16px',
                lineHeight
            }
            let rows = [];
            let lines = text?.split('\n') || [];
            lines.forEach(el => {
                let ite = this.getMutiTextRows(ctx, el, w, fontObj);
                ite.length && rows.push(...ite);
;            })
            let height = rows.length * lineHeight;
            return {
                x: this.data.canvasLrPadding,
                y: hasy + 30, // 20--日记内容和标题垂直间距
                content: rows,
                width: w,
                height,
                ...fontObj
            }
        },
        // 日记图片
        async getDiaryImgRender(canvas, imageUrls, dvwidth, hasy) {
            let that = this;
            let imgw = 98;
            let imgh = 98;
            const promises = imageUrls.map(url => {
                return new Promise((resolve, reject) => {
                  let tempimg = canvas.createImage();
                  tempimg.src = url;
                  tempimg.onload = function() {
                    resolve(tempimg)
                  }
                })
            })
            let images1 = await Promise.all(promises);
            return {
                x: this.data.canvasLrPadding + 20, // 20-图片的左边距比整体多
                y: hasy + 20, // 20--日记内容和标题垂直间距;整体图片区域的 y坐标
                content: images1,
                width: dvwidth - this.data.canvasLrPadding * 2 - 20,
                height: Math.ceil(imageUrls.length / 3) * imgh,
                fontColor: '#222', // 后面改成根据背景色取
                fontSize: '16px',
                imgw,
                imgh
            }
        },
        // 打卡人
        getUserNameRender(ctx, text, dvwidth, hasy) {
            let fontSize = '16px';
            let fontColor = '#333';
            ctx.fillStyle = fontColor;
            ctx.font = `${fontSize} ${this.data.fontFamily}`;
            let width = ctx.measureText(text).width;
            let lineHeight = 20;
            return {
                x: dvwidth - this.data.canvasLrPadding - width,
                y: hasy + 20, // 20--垂直间距
                content: text,
                width: width,
                height: lineHeight,
                lineHeight,
                fontSize,
                fontColor
            }
        },
        // 多行文本绘制
        drawerMutiText(ctx, rows, x, y, fontObj, lineHeight) {
          ctx.fillStyle = fontObj?.fontColor || '#333';
          ctx.font = `${fontObj?.fontWeight ? (fontObj?.fontWeight + ' ') : ''}${fontObj?.fontSize} ${this.data.fontFamily}`;
          for (let i = 0; i < rows.length; i++) {
            let rtext = rows[i];
            ctx.fillText( rtext|| '', x, y + i* lineHeight);
          }
        },
        // 赞和评论数
        getZanCommentRender(ctx, canvas, data, dvwidth, hasy) {
            let fontSize = '14px';
            let fontColor = '#333';
            ctx.fillStyle = fontColor;
            ctx.font = `${fontSize} ${this.data.fontFamily}`;
            let iconsize = 25;
            let x1 = this.data.canvasLrPadding + 20; // 20-图片的左边距比整体多
            let x1w = iconsize;
            let x2 = x1 + x1w + 2;
            let x2text = data?.like_user_list?.length ;
            let x2w = ctx.measureText(x2text)?.width || 0;
            let x3 = x2 + x2w + 30;
            let x3w = iconsize;
            let x4 = x3 + x3w + 4;
            let x4text = data?.comment_list?.length;
            let x4w = ctx.measureText(x2text)?.width || 0;
            let icons = this.data.zancommenticons.map(url => {
                return new Promise((resolve, reject) => {
                  let tempimg = canvas.createImage();
                  tempimg.src = url;
                  tempimg.onload = function() {
                    resolve(tempimg)
                  }
                })
            })
            return {
                x1, 
                x2,
                x3,
                x4,
                iconsize,
                icons,
                height: iconsize,
                y: hasy + 20,
                yTxt: hasy + 20 + 10 + 10,
                content2: x2text,
                content4: x4text,
                fontColor,
                fontSize
            }
        }