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

旧状

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

一样的卡片内容咱们须要在两端以两种框架(vue react)分别维护。css

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

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

好比:vue

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

因此咱们定义的 canvas-utils 入参也必须包含这些位置、尺寸信息。java

/** * 绘制圆角矩形 * * @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,只须要在设计稿上量好尺寸以及位置,就能将对应的元素绝对定位到画布上。react

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

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 再进行画图操做。typescript

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

好比:

  • 画图前须要先 load 图片地址,涉及异步,这是比较冗余的操做
  • 一直调 draw*** 方法,传类似的参数,这也是冗余操做,采用 json 配置参数会不会更好?
  • 若是生成图片的高度须要自适应多个子元素的高度?这须要写不少额外逻辑。
  • 若是两种不一样样式的文字横向居中显示?又要疯狂的计算再传入 x y 定位,总之涉及到自适应样式的需求咱们就得在逻辑中频繁的计算。

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

如何定义 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,共同构建一颗完整的节点树。

用 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 定位,总之涉及到自适应样式的需求咱们就得在逻辑中频繁的计算。

好比说下图的样式,横向布局,有不一样的文字大小以及样式,并且文字的个数仍是自定义的:

这三个节点咱们都要实时计算 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…

涉及到盒模型相关的 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 用来描述纵向排列仍是横向排列。

摘取部分须要的属性:

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。

总体流程:

根据 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 的元素修改结构

如图所示,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 图片

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 绘制文字有本身的基准规则

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

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

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

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

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

Further

这套画图库的效果其实很相似 html2canvas 这个类库了,可是 json2canvas 的形式其实还有其余能够想象的空间。

好比

  • 能够直接经过 sketch 根据图层直接生成匹配的 json 数据,而 json 数据是适配不一样前端框架的。
  • 这个类库的大部分实现是如何计算各个节点的盒模型尺寸位置,而这也是跟平台无关的,能够很快速的迁移至小程序中。小程序中仅仅兼容下画图 api 就能够了。
  • 若是在各个前端框架层以为配置 json 不太直观,能够在组件层建立几个关键组件 <Div style={}> <Text style={}> <Image style={}>,而后就能够像写 html 同样去写 canvas。这也相似 html2canvas 的写法。
相关文章
相关标签/搜索