更优雅地基于 canvas 在前端画海报

栏目: Html5 · 发布时间: 5年前

内容简介:我们的业务涉及电商、教育行业,出于营销以及功能需要,会有很多卡片展示(长按保存)的需求,或者分享长图的需求。以及我们有面向商家的PC端,商家端又能编辑、实时预览卡片的样式。同样的卡片内容我们需要在两端以两种框架(vue react)分别维护。考虑到依赖太大(ungzipped 160kb+)、稳定性、可维护性、可拓展性等因素,我们没有采用html2canvas 这个第三方转换库。而是采用抽离一系列

我们的业务涉及电商、教育行业,出于营销以及功能需要,会有很多卡片展示(长按保存)的需求,或者分享长图的需求。以及我们有面向商家的PC端,商家端又能编辑、实时预览卡片的样式。

更优雅地基于 canvas 在前端画海报

同样的卡片内容我们需要在两端以两种框架(vue react)分别维护。

考虑到依赖太大(ungzipped 160kb+)、稳定性、可维护性、可拓展性等因素,我们没有采用html2canvas 这个第三方转换库。而是采用抽离一系列 canvas-utils 的方式进行 canvas 画图。

因为 canvas 原生的绘图 api 都是以绝对定位的像素点,再辅以尺寸信息进行绘制。

比如:

ctx.rect(x, y, width, height); // 画矩形
ctx.drawImage(img, destx, desty, destWidth, destHeight); // 画图片
复制代码

所以我们定义的 canvas-utils 入参也必须包含这些位置、尺寸信息。

/**
 * 绘制圆角矩形
 *
 * @param {*} ctx 画布
 * @param {Number} radius 半径
 * @param {Number} x 左上角
 * @param {Number} y 左上角
 * @param {Number} width 宽度
 * @param {Number} height 高度
 * @param {String} color 颜色
 * @param {String} mode 填充模式
 * @param {Function} fn 回调函数
 */
export function drawRoundedRectangle() {}

/**
 * 绘制图片(方、圆角、圆)
 *
 * @param {*} ctx 画布
 * @param {*} img load好的img对象
 * @param {Number} x 左上角定点 x 轴坐标
 * @param {Number} y 左上角定点 y 轴坐标
 * @param {Number} w 宽
 * @param {Number} h 高
 * @param {Number} radius 圆角半径
 */
export function drawImage() {}

/**
 * 绘制多行片段
 *
 * @param {*} ctx         画布
 * @param {*} content     内容
 * @param {*} x           绘制左下角原点 x 坐标
 * @param {*} y           绘制左下角原点 y 坐标
 * @param {*} maxWidth    最大宽度
 * @param {*} fontSize    字体大小
 * @param {*} fontFamily  字体家族
 * @param {*} color       字体颜色
 * @param {*} textAlign   字体排布
 * @param {*} lineHeight  设置行高
 * @param {*} maxLine 最大行数
 */
export function drawParagraph() {}

/**
 * 创建一个画布
 *
 * @param {*} width 宽
 * @param {*} height 高
 * @return {*} canvasAndCtx 画布相关信息
 */
export function initCanvasContext(width, height) {
  return [canvas, ctx];
}
复制代码

这四个核心方法涵盖了几乎所有海报画图类需求,图片、段落文字、背景容器、画布创建。并且已经把 canvas 相关的 api 收拢了,开发者无需关注恼人的 canvas api,只需要在设计稿上量好尺寸以及位置,就能将对应的元素绝对定位到画布上。

大概业务中的实现(伪代码):

Promise.all([
      canvasUtils.loadUrlImage(mainCoverImg),
      canvasUtils.loadBase64Image(cardInfo.qrCode),
    ])
      .then(([cover, qrCode, shopnameIcon, titleIcon]) => {
  const [canvas, ctx] = canvasUtils.initCanvasContext(325, 564);

  // 绘制底框
  canvasUtils.drawRoundedRectangle(ctx, ...sizeMapValue.base);

  // 绘制封面图
  canvasUtils.drawImage(ctx, ...sizeMapValue.cover);

  // 绘制标题
  canvasUtils.drawParagraph(ctx, ...sizeMapValue.title);

  // 绘制题数
  canvasUtils.drawImage(ctx, ...sizeMapValue.titleIcon);
  
  // ...

  return canvas.toDataURL('image/png');
      })
复制代码

因为图片的入参是个 img 对象,需要先 load 图片链接,这里就有个异步的过程,所以设计之初就规定先 Promise.all 所有图片拿到 img 再进行画图操作。

采用这种方式画海报能实现基本需求,但也有一定局限性。

比如:

draw***

那么,如何改善这些问题,在前端更优雅地画海报呢?

如何定义 schema

不使用 html2canvas 还有个原因是该库基于 htmlElement,公司现状下 jsx 和 vue 模板语法不兼容,无法复用代码片段,还有个更重要的原因是小程序没法用,那么采用什么类型的 schema 去收敛 api,以及最大化在不同平台兼容?

这里采用了 json 的形式去配置化参数生成图片。

基础 schema:

{
  type: '',
  css: {},
  custom: null, // 自定义回调
}
复制代码

之前的核心 drawImage drawParagraph drawRoundedRectangle 方法目的就是绘制 图片、文字、容器,对于这三个类型分别有不同的额外配置,需要不同的更具语义化的 schema。

图片:

{
  type: 'image',
  css: {},
  url: '',
  mode: 'fill | contain',
  custom: null,
};
复制代码

文字:

{
  type: 'text',
  css: {},
  text: '',
  custom: null,
};
复制代码

容器:

{
  type: 'div',
  css: {},
  mode: 'div | line',
  children: [],
  custom: null,
}
复制代码

type 为 div 类型的 schema 相当于是个容器,具有 children 字段,与 html 中的 div 概念也类似,div 可以嵌套承载更多的 div、text、image,共同构建一颗完整的节点树。

更优雅地基于 canvas 在前端画海报

用 json schema 去描述一张卡片的伪代码:

{
    type: 'div',
    css: {},
    children: [
      {
        type: 'div',
        css: {},
        children: [
          {
            type: 'text',
            css: {},
            text: '文字一'
          },
          {
            type: 'image',
            css: {},
            url: 'cdn.image.com/test1',
            mode: 'contain'
          }
        ]
      },
      {
        type: 'text',
        css: {},
        text: '好多文字 好多文字 好多文字'
      },
    ]
  }
复制代码

使用 json schema 去描述视图,已经解决了之前 canvas-utils 方案的几个局限性。

画图前需要先 load 图片地址,涉及异步,这是比较冗余的操作

传入给 image 的是 url 地址或者是 base64字符串,load 图片的操作会在内部实现,外部无需关心。

一直调 draw*** 方法,传相似的参数,这也是冗余操作,采用 json 配置参数会不会更好?

所有的方法调用被 type 替代,原先必传的 尺寸、位置信息

canvasUtils.drawParagraph(ctx, cardInfo.title, 14, 380, 285, 14, undefined, undefined, undefined, 20, 2);

被 css 字段代替:

{
  type: 'text',
  css: {
    width: '285px',
    height: '14px',
    x: '14px',
    y: '380px',
    ...
  },
  text: cardInfo.title,
  custom: null,
};
复制代码

绝对定位的布局系统的缺陷

现在的 schema 定义在实现的功能上跟之前的 canvas-utils 本质上没什么区别,只是简化了使用姿势,所有的节点都是按照绝对定位,我们需要手动传入所有节点的尺寸信息(width height)以及位置信息(x y),现在市面是几乎所有类似 jsonToCanvas 的类库都是这样设计,但这样并不能解决我们提到的几个局限性。

  • 如果生成图片的高度需要自适应多个子元素的高度?这需要写很多额外逻辑。
  • 如果两种不同样式的文字横向居中显示?又要疯狂的计算再传入 x y 定位,总之涉及到自适应样式的需求我们就得在逻辑中频繁的计算。

比如说下图的样式,横向布局,有不同的文字大小以及样式,而且文字的个数还是自定义的:

更优雅地基于 canvas 在前端画海报

这三个节点我们都要实时计算 width height x y ,再传入 css 字段,工作量还是巨大的。

既然我们的 schema 在描述图片结构上(嵌套)的向 html 靠齐,那么我们 css 字段 的 schema 为什么不向真实的 css 靠齐?

借助 margin 块状流式布局,借助 inline-block 横向布局,将之前的绝对定位改成 css 默认的 相对定位,模拟 css 的能力。

更重要的是模拟实现 css属性 的强大继承能力,这样我们在定义某个节点的 css 属性时,就不用把各种属性再写一遍,直接依赖父节点css属性的继承。

暴露给用户使用的 schema 需要足够智能,把需求计算的需求在组件内部吃掉。

原本的定义:

{
  "type": "div",
  "css": {
    "width": "200px",
    "height": "200px",
    "x": "0px",
    "y": "0px",
  },
  "children": [
    {
      "type": "text",
      "css": {
        "width": "动态计算",
        "height": "动态计算",
        "x": "动态计算",
        "y": "动态计算",
        "fontSize": "12px"
      },
      "text": "自定义文案:"
    },
    {
      "type": "text",
      "css": {
        "width": "动态计算",
        "height": "动态计算",
        "x": "动态计算",
        "y": "动态计算",
        "fontSize": "16px",
        "color": "red"
      },
      "text": "我后面跟这张图片"
    },
    {
      "type": "image",
      "css": {
        "width": "15px",
        "height": "15px",
      },
      "url": "https://su.yzcdn.cn/public_files/2018/12/14/61d0dad50c5b2789a0232c120ae5f7fa.jpg",
      "mode": "contain"
    }
  ]
}
复制代码

更智能的定义:

{
  "type": "div",
  "css": {
    "width": "200px",
    "height": "200px",
  },
  "children": [
    {
      "type": "text",
      "css": {
        "display": "inline-block",
        "marginTop": "3px",
      },
      "text": "自定义文案:"
    },
    {
      "type": "text",
      "css": {
        "display": "inline-block",
        "fontSize": "16px",
        "color": "red"
      },
      "text": "我后面跟这张图片"
    },
    {
      "type": "image",
      "css": {
        "width": "15px",
        "height": "15px",
        "display": "inline-block"
      },
      "url": "https://su.yzcdn.cn/public_files/2018/12/14/61d0dad50c5b2789a0232c120ae5f7fa.jpg",
      "mode": "contain"
    }
  ]
}
复制代码

我们可以看到优化后的版本并不需要指定文字的宽度高度,也不用指定图片的位置信息,就跟写原生 css html 一致。

优化 css schema 来处理动态尺寸的需求

既然要靠齐 css 的能力,那 css schema 的定义也就要参照css2.1 规范进行,我们定义的 css schema 是 css2.1 规范的子集。

那我们去寻找规范中有哪几个集合是适用我们的 case。

box model

www.w3.org/TR/CSS2/box…

更优雅地基于 canvas 在前端画海报

涉及到盒模型相关的 css 属性

export interface IBoxModel {
  marginLeft: string;
  marginRight: string;
  marginTop: string;
  marginBottom: string;
  borderWidth: string;
  borderColor: string;
  borderStyle: 'solid' | 'dashed';
  borderRadius: string | undefined;
  boxShadow: string | undefined;
  customVerticalAlign: 'down' | 'top' | 'center';
  customAlign: 'left' | 'right' | 'center';
}
复制代码

visual formatting model

www.w3.org/TR/CSS2/vis…

可视格式化模型也是 css 规范中除了 盒模型(box model)外最为重要的模型,他描述了基于盒模型的元素是如何排列在可视化窗口中的,比如 position 来描述是绝对定位还是相对定位。display: block | inline-block 用来描述纵向排列还是横向排列。

更优雅地基于 canvas 在前端画海报

摘取部分需要的属性:

export interface IVisFormatModel {
  width: string;
  height: string;
  maxWidth: string | undefined;
  maxHeight: string | undefined;
  minWidth: string;
  minHeight: string;
  position: 'absolute' | 'relative';
  top: string | undefined;
  left: string | undefined;
  right: string | undefined;
  bottom: string | undefined;
  display: 'block' | 'inline-block';
}
复制代码

Colors and Backgrounds

www.w3.org/TR/CSS2/col…

用来描述颜色和背景

export interface IColorAndBg {
  color: string;
  backgroundColor: string;
}
复制代码

Fonts

www.w3.org/TR/CSS2/fon…

用来描述单个文字的具体样式,大小、字体等。

export interface IFonts {
  lineHeight: string | undefined; // line-height 应该属于 visual formatting model,但与传统的 css 不太一样,我们规定在无法在 div 中写文字
  fontStyle: string;
  fontFamily: string;
  fontWeight: number;
  fontSize: string;
}
复制代码

Text

www.w3.org/TR/CSS2/tex…

与 Fonts 不同,这个规范是为了描述文字之前的排列行为,比如对其方式,是否有中划线等。

export interface IText {
  textAlign: 'left' | 'right' | 'center';
  lineClamp: number | undefined; // 不在 css2.1 规范内,方便描述几行文字拦截展示 【...】
  textDecoration: 'line-through' | undefined;
}
复制代码

画图库的实现过程,计算盒模型

不管我们的 css schema 定义的如何对用户友好,在组件内部最终调用 canvas api 的时候我们还是需要传入绝对定位的尺寸以及位置。

定义好了元素类型的 schema 以及 css 的 schema,需要实现的就是在组件内部根据节点的 css属性 计算各个节点的盒模型尺寸,再由最终的盒模型数据,绘制出最终的 canvas。

整体流程:

更优雅地基于 canvas 在前端画海报

根据 css 计算得到盒模型数据,是画图库代码量最大的步骤。以下就是计算盒模型的计算流程。

const defaultConfig = canvasWrap.setDefault(copyConfig);

const inlineBlockConfig = canvasWrap.setInlineBlock(defaultConfig);

const widthConfig = canvasWrap.addWidth(inlineBlockConfig);

const heightConfig = canvasWrap.addHeight(widthConfig);

const originConfig = canvasWrap.addOrigin(heightConfig);
复制代码

setDefault 设置默认值

因为 schema 允许部分字段不传,所以第一步递归遍历传入的数据源,将默认值赋值给入参。

setInlineBlock 将 inline-block 的元素修改结构

更优雅地基于 canvas 在前端画海报

如图所示,setInlineBlock 方法会将连续排列的 inline-block 节点聚合,新建一个空白的 div 插入原先的位置,然后将这些 inline-block 节点作为 children 插入其中,这样做的目的在于方便后面的 width height 计算。

addWidth 计算所有节点的宽

遍历所有节点,如果发现是有 children 的 div,则继续递归遍历。

模拟原生 css 特性,如果当前节点设置了 width,则取当前宽,否则取父节点计算完的宽。

当然还有许多 css 属性会影响到 width 最终的计算,比如 minWidth maxWidth,又比如子节点元素是否都是 inline-block。

再比如当前的 type 为 text,而且又没有设置 width,这里就得调用 canvas 提供的 ctx.measureText(content).width; 去获取 width。

计算完的 width 会结合 margin,border 等 css 属性再次计算各种盒模型宽。

const sumWidth = calRealdemension(sumWidth, [css.minWidth, css.maxWidth]);
const layerWidth = sumPixels(sumWidth, marginWidth);
const contentWidth = minusPixels(sumWidth, addedBorderWidth);

addBoxWidth(element, sumWidth);
addLayerWidth(element, layerWidth);
addContentWidth(element, contentWidth);
复制代码

这里会将计算完的数据直接赋值给当前 config 对象,这样在递归到下一层 children 时就可以直接使用父节点 width 了。

addHeight 计算所有节点的高

与计算宽度大同小异,这里不再赘述。

addOrigin 计算所有节点的位置

既然已经计算得出所有节点的尺寸信息,同样递归遍历所有的节点,以父节点为基准就能计算得到所有子节点的位置信息。

更优雅地基于 canvas 在前端画海报

绘制 canvas 图片

const images = canvasWrap.getImages(originConfig);

images.then(imgMap => {
    resolve(canvasWrap.drawCanvas(originConfig, imgMap));
})
复制代码

得到所有节点的位置、尺寸信息,再结合统一 load 的图片信息,最后就可以使用 canvas-utils 中的绘制方法,进行图片绘制了。

自定义插槽 custom

最后再提一下定义 schema 时预留的 custom 字段,可以传回调函数进去,暴露出来的参数为 ctx,用来调用 canvas 绘制 api,以及该节点的盒模型数据,这样用户就能知道当前节点的范围。

custom(canvas, ctx, config) {
  ctx.beginPath();
  ctx.moveTo(config.origin.x, config.origin.y);
  ctx.lineTo(50, 40);
  ctx.stroke();
},
复制代码

canvas 绘图的注意点

生成图片模糊问题

当我们直接给 canvas 设定 width,height 时,比如

<canvas width="200" height="200"></canvas>
复制代码

这实际告诉浏览器的是以位图(bitmap)的形式生成一张 200x200 物理像素点的画布,我们可以直接看成是一张图片。

如果没有人为的用 css 指定这张画布的逻辑宽高,那么浏览器默认会设置成 200px x 200px。

我们可以直接想象成将一张 200x200 的位图,以 css 200x200 设置。这就相当于前端工程师熟知的高分辨率下 2 倍图优化问题。

解决方式也就类似解决 2 倍图问题,将 canvas 的宽高放大 n 倍(n 取决于 window.devicePixelRatio ),css 设置成原宽高。

function initCanvasContext(width: number, height: number): [HTMLCanvasElement, CanvasRenderingContext2D] {
  canvas.width = width * window.devicePixelRatio;
  canvas.height = height * window.devicePixelRatio;
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
  return [canvas, ctx];
};
复制代码

如何用 canvas 绘制文字段落

使用 ctx.fillText(content, x, y); 绘制段落时,y 的定位并不在文字的下方。

比如我们绘制两条 y 分别为 10 24 的直线,再绘制 y 为 24 的文字:

更优雅地基于 canvas 在前端画海报

原因是 canvas 绘制文字有自己的基准规则

更优雅地基于 canvas 在前端画海报

默认文字的基准线就是偏下,这里做过实验,在不同系统设备上各个基准都不太一样,包括 bottom ideographic ,唯独 middel 的样式在各个平台上表现是一致的。

所以这里有个取巧的方法,可以使文字是上下居中的。

ctx.textBaseline = 'middle'; // 适配安卓 ios 下的文字居中问题

ctx.save();
ctx.translate(0, -(fontSize / 2)); // 适配安卓 ios 下的文字居中问题
ctx.fillText(content, x, y);
ctx.restore();
复制代码

先将文字基准线居中,再在绘制文字的时刻改变坐标系,画完后改变成原来的坐标系。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Python for Data Analysis

Python for Data Analysis

Wes McKinney / O'Reilly Media / 2012-11-1 / USD 39.99

Finding great data analysts is difficult. Despite the explosive growth of data in industries ranging from manufacturing and retail to high technology, finance, and healthcare, learning and accessing d......一起来看看 《Python for Data Analysis》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

随机密码生成器
随机密码生成器

多种字符组合密码

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具