【html2canvas】生成图片的字符重叠

4,736 阅读5分钟

背景

项目中有一个制作电子奖状的需求,需要根据奖状模板和用户编辑的文字生成不同的奖状图片,因此使用了 html2canvas 来将 DOM 元素转为 canvas 再生成图片。

问题

在 html 转为 canvas 过程中,若文段元素的自动换行处前有引号(其他符号都没事,就引号有问题),生成图片的对应文段会换行并与下一行行首的字符重叠,如下图

解决过程

1、首先尝试各种更改 css 属性值,包括 white-spaceword-break 等都无济于事

2、Google 查到类似问题,有人通过改变 html2canvas 源码中的 SUPPORT_RANGE_BOUNDS 值为 false 来解决,亲测无效

3、但接着在 SUPPORT_RANGE_BOUNDS 的相关代码附近发现,有一句可疑的 offset += text.length,怀疑这整个 parseTextBounds 方法可能就是导致问题出现的罪魁祸首 于是接着排查,打印出了 textList 发现一般的字符都会被单独分割出,普通符号(如最后的感叹号)则会与前一个字符合并,但引号却特殊地把后一个字符也合并在一块

4、于是解决问题的方向转为怎么消除引号与后一个字符的连接,在代码上文对 textList 的获取方式中看到了 letterRendering 的判断: 尝试更改 letterRendering 的值,将 parent.style.letterSpacing !== 0 改为全等,之后打印 textList 发现符号也单独被分割出了,且生成的图片终于不会出现引号换行、字符重叠的情况:

5、从上图的代码可以看到,决定 letterRendering 的值的就是父元素的 letter-spacing 属性,而我的代码中的文段元素 CSS 属性原来并没有设置 letter-spacing 的值,于是在不改变 html2canvas 源码的情况下,将文段元素的 letter-spacing 设为 0.1px,发现图片样式正常,问题解决

分析

为什么 textList 中引号与后一字符相连就会导致样式重叠的情况出现呢?

1、思路首先是要知道 textList 生成之后的用处,从下方代码可看出 textList 主要是服务于 textBounds 的生成: 于是打印出方法最后返回的 textBounds 的内容: 可以看出,这整个方法的作用就是计算每一组分割出的字符的位置、宽高数据,初步猜测就是用于 canvas 的绘制生成

然而从打印出的数据上来看还是有些匪夷所思:
这四个数据组成了左上角点的坐标+宽高,也就是元素所占据的面积,所以可以得出的是其他组都是单个字所占据的小面积,而 生”荣 占据的面积却横跨了两个行段: 那按照逻辑来想,如果 textList 的数据真是用于文本的绘制,那么绘制出的 生”荣 不就应该占据在上面边框左上角的“获得”的位置吗?为什么是出现在“荣”字的位置?

2、那么接着就需要确定 textList 的真正用处了
查找了 parseTextBounds 方法用到的地方,发现会在新建 TextContainer 对象时作为其 bounds 属性值传入: 接着查找 bounds 属性发现其被使用到的地方还是很多的,观察函数名大都是 render xxx element 之类,于是这里直接定位到听名字最有可能的 renderTextNode 方法,并在其中找到 bounds 作为 ctx.fillText 的参数使用: 那么 bounds 数据是用于 canvas 绘制的想法就证实了

3、而文本绘制的位置为何在左下角而不是左上角呢?这就与 canvas 的 fllText 方法有关了
因为传入 fillText 的 x、y 坐标点决定的是绘制文本的 textBaseline 基线,而文字自然是要绘制在基线之上的,因此直观看来这个点决定的就是文字的左下角位置了
也因此,可以看到 html2canvas 在传入该点的 y 时,利用的是 top+height,而不仅仅是 top

4、综上,生”荣 即会被绘制到所占面积的左下角,也就是原本 字的位置,那么在后面的其他字符都按照原位置被准确计算且绘制的情况下,出现字符重叠的情况也就理所当然了

为什么引号会特殊地跟前后字符都连接在一块儿呢?

可以看到 letterRendering 为 false 时,文段会以 _Unicode.breakWords 方法来分割,否则会用 _Unicode.toCodePoints 分割后再逐个遍历调用 _Unicode.fromCodePoint 方法 深入发现 _Unicode.toCodePoints 方法是将字符逐个拆开转为 Unicode 编码: 后在 _Unicode.fromCodePoint 中再将编码转为字符列表,如此分割出的字符便是完全独立的
_Unicode.breakWords 较为复杂,经过一段时间的查阅后得出其调用了 css-line-break 模块的字符分割方法,而其方法中会对各种符号进行特殊处理,使其与前一个字符保持相连: 唯独双引号,连后面的字符也不给中断:

🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻🤷🏻

👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎👎

相关源码

parseTextBounds
var parseTextBounds = exports.parseTextBounds = function parseTextBounds(value, parent, node) {
  var letterRendering = parent.style.letterSpacing !== 0;
  var textList = letterRendering ? (0, _Unicode.toCodePoints)(value).map(function (i) {
    return (0, _Unicode.fromCodePoint)(i);
  }) : (0, _Unicode.breakWords)(value, parent);
  var length = textList.length;
  var defaultView = node.parentNode ? node.parentNode.ownerDocument.defaultView : null;
  var scrollX = defaultView ? defaultView.pageXOffset : 0;
  var scrollY = defaultView ? defaultView.pageYOffset : 0;
  var textBounds = [];
  var offset = 0;
  for (var i = 0; i < length; i++) {
    var text = textList[i];
    if (parent.style.textDecoration !== _textDecoration.TEXT_DECORATION.NONE || text.trim().length > 0) {
      if (_Feature2.default.SUPPORT_RANGE_BOUNDS) {
        textBounds.push(new TextBounds(text, getRangeBounds(node, offset, text.length, scrollX, scrollY)));
      } else {
        var replacementNode = node.splitText(text.length);
        textBounds.push(new TextBounds(text, getWrapperBounds(node, scrollX, scrollY)));
        node = replacementNode;
        }
    } else if (!_Feature2.default.SUPPORT_RANGE_BOUNDS) {
      node = node.splitText(text.length);
    }
    offset += text.length;
  }
  return textBounds;
};
TextContainer
var TextContainer = function () {
  function TextContainer(text, parent, bounds) {
    _classCallCheck(this, TextContainer);

    this.text = text;
    this.parent = parent;
    this.bounds = bounds;
  }

  _createClass(TextContainer, null, [{
    key: 'fromTextNode',
    value: function fromTextNode(node, parent) {
      var text = transform(node.data, parent.style.textTransform);
      return new TextContainer(text, parent, (0, _TextBounds.parseTextBounds)(text, parent, node));
    }
  }]);

  return TextContainer;
}();
renderTextNode
function renderTextNode(textBounds, color, font, textDecoration, textShadows) {
  var _this4 = this;

  this.ctx.font = [font.fontStyle, font.fontVariant, font.fontWeight, font.fontSize, font.fontFamily].join(' ');

  textBounds.forEach(function (text) {
    _this4.ctx.fillStyle = color.toString();
    if (textShadows && text.text.trim().length) {
      textShadows.slice(0).reverse().forEach(function (textShadow) {
        _this4.ctx.shadowColor = textShadow.color.toString();
        _this4.ctx.shadowOffsetX = textShadow.offsetX * _this4.options.scale;
        _this4.ctx.shadowOffsetY = textShadow.offsetY * _this4.options.scale;
        _this4.ctx.shadowBlur = textShadow.blur;

        _this4.ctx.fillText(text.text, text.bounds.left, text.bounds.top + text.bounds.height);
      });
    } else {
      _this4.ctx.fillText(text.text, text.bounds.left, text.bounds.top + text.bounds.height);
    }
  // ...