背景
项目中有一个制作电子奖状的需求,需要根据奖状模板和用户编辑的文字生成不同的奖状图片,因此使用了 html2canvas 来将 DOM 元素转为 canvas 再生成图片。
问题
在 html 转为 canvas 过程中,若文段元素的自动换行处前有引号(其他符号都没事,就引号有问题),生成图片的对应文段会换行并与下一行行首的字符重叠,如下图
解决过程
1、首先尝试各种更改 css 属性值,包括 white-space、word-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);
}
// ...