Sep
21
最近使用html2canvas时遇到一个文字排版错误的缺陷,虽然html2canvas本身兼容性问题挺多的,但是这个看起来是一个广泛发生的问题,便进行了深入追踪。问题现象如下:
相关issues包括 https://github.com/niklasvh/html2canvas/issues/2648,https://github.com/niklasvh/html2canvas/issues/2696等,挺多的。
缺陷本身
这个缺陷排查起来并不复杂,html2canvas可以清晰的分裂为排版与渲染两部分。
- 排版部分通过将渲染dom克隆到独立的iframe中,通过dom自带的测量方法即入题所说的getClientRects对每个元素进行测量。
- 渲染部分则根据上一步测量的数据,通过canvas render进行绘制。
断点后,即发现排版部分部分获取的数据即出现了问题:
1. 对于文字段落,html2canvas会逐个词/文字的查询其排版位置(text.ts的parseTextBounds),这个过程的实现方案是通过document.createRange()去选中,然后对range通过parseTextBounds获取其实际位置。
2. 首先检查的iframe本身生成是否正确,将该iframe放到body中查看,确实是正确的。
3. 对于文字逐个排版扫描的结果进行判断,发现问题,实际上每一行第一个词/字符的位置错误,变成了第一行的末尾。
继续追踪为何会出现这个错误,发现在出错的字符进行getClientRects测量时,一般来说,对单个词/文字进行selection出的range对象进行测量,总是是使用了分词折行就是词否则按字符,怎么想都只会有一个rect,但是出问题的情况下即在safari中符合如山情况的字符,这个字符测量出的rect居然有两个:
其中第一个rect是一个宽度为0的空rect,而第二个看起来才是实际有渲染数据的rect。如果用形象的话描述,进行跨行的selection时,safari会同时选中第一行末尾和第二行的实际第一个字符,因此出现了两个rect。
但是好巧不巧的是,在html2canvas的实现代码中,估计和大家一般想想的那样,选择单个字符不应该出现多个rect,因此在代码bounds.ts中生成Bounds对象时,直接选择了数组[0]:
因此在刚才safari的异常情况下,选中了位于第一行的宽度为0的rect去构成Bounds,因此产生了这个计算错误。如图勾选的字符产生了两个红色rect,其中第一个只是一条线,但是html2canvas默认第一个即文字位置,因此位置错误,如绿色那样。
实际讨论与修复
首先我们需要判断的是,按照html2canvas设计思路,一个独立的分词/一个字符渲染时只有一个rect是否合理。这里参考的常用文字,与各种表情包会使用的超长/超界限的特殊字符,主观结论是:合理。
同时我们也可以认为safari输出了多余的空白rect是没有必要的行为,可能是缺陷(但是实测从iOS10到15的移动端safari均有这个问题)。
同时Bounds的构造方法:fromDOMRectList(context: Context, domRectList: DOMRectList): Bounds,这里输入DOMRectList会让人产生误解即Bounds可以处理多个DOMRect并进行渲染——事实上只是取了第一个。
因此修复方案与结论是:
1. 该问题是safari问题,以兼容为主:对于该场景输出的DOMRectList,只取第一个非0的DOMRect,并且在实际输出多个非0的DOMRect时认为不符合预期,发出线下异常。
2. Bounds对象合理性上,构造器改为输入当个DOMRect,选取DOMRect工作由使用场景去选择。
实际的修复结果在我遇到问题的场景是完全符合预期的,也能解决issues上的问题。不过实际进行极端测试的时候,同时还有个别分词上的问题,由于过于极端,暂时略过。
getClientRects的问题
回到getClientRects,虽然我们说对于一个分词/一个字符只返回一个DOMRect主观判断是合理的,但是从标准上是合理的么?查询mdn(https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getClientRects)与更有历史的http://help.dottoro.com/ljxsdaqk.php文档,我们注意到事实上提过这个:
那么我们的预期是:
- 正常情况下,block元素均只有一个rect。
- 对于inline元素,每一行有一个rect。但是inline一个元素是否被认为跨行确实是未定义行为,我们确实无法预期未来一个分词或者一个字符可能会被夸行。
因此对于我们使用getClientRects的指导是:我们需要考虑一个渲染对象跨行即在多个rect中分割渲染的情况。对于html2canvas的case,即渲染一个目标的时候,它可以被渲染到多个rect中,并且可横行排列组合成最后图像。
同时,这两篇文档上也提到getClientRects的演变史上,在IE/火狐等旧浏览器上均出现过各种需要兼容的异常行为。如果考虑支持较为旧的浏览器,这个方法坑是真多。github上有人做兼容库:https://github.com/edg2s/rangefix
它修复了包括:
不过这个库确实没有包含本次遇到的问题,同时目前看更新不频繁使用的人也不多,估计是这个方法确实本身使用的人也不多。实际使用情况下,重依赖getClientRects的代码,包括html2canvas使用并维护一个兼容库确实是一个更合理的选择。
相关issues包括 https://github.com/niklasvh/html2canvas/issues/2648,https://github.com/niklasvh/html2canvas/issues/2696等,挺多的。
缺陷本身
这个缺陷排查起来并不复杂,html2canvas可以清晰的分裂为排版与渲染两部分。
- 排版部分通过将渲染dom克隆到独立的iframe中,通过dom自带的测量方法即入题所说的getClientRects对每个元素进行测量。
- 渲染部分则根据上一步测量的数据,通过canvas render进行绘制。
断点后,即发现排版部分部分获取的数据即出现了问题:
1. 对于文字段落,html2canvas会逐个词/文字的查询其排版位置(text.ts的parseTextBounds),这个过程的实现方案是通过document.createRange()去选中,然后对range通过parseTextBounds获取其实际位置。
2. 首先检查的iframe本身生成是否正确,将该iframe放到body中查看,确实是正确的。
3. 对于文字逐个排版扫描的结果进行判断,发现问题,实际上每一行第一个词/字符的位置错误,变成了第一行的末尾。
继续追踪为何会出现这个错误,发现在出错的字符进行getClientRects测量时,一般来说,对单个词/文字进行selection出的range对象进行测量,总是是使用了分词折行就是词否则按字符,怎么想都只会有一个rect,但是出问题的情况下即在safari中符合如山情况的字符,这个字符测量出的rect居然有两个:
其中第一个rect是一个宽度为0的空rect,而第二个看起来才是实际有渲染数据的rect。如果用形象的话描述,进行跨行的selection时,safari会同时选中第一行末尾和第二行的实际第一个字符,因此出现了两个rect。
但是好巧不巧的是,在html2canvas的实现代码中,估计和大家一般想想的那样,选择单个字符不应该出现多个rect,因此在代码bounds.ts中生成Bounds对象时,直接选择了数组[0]:
static fromDOMRectList(context: Context, domRectList: DOMRectList): Bounds {
const domRect = domRectList[0];//问题在这里
return domRect
? new Bounds(
domRect.x + context.windowBounds.left,
domRect.y + context.windowBounds.top,
domRect.width,
domRect.height
)
: Bounds.EMPTY;
}
因此在刚才safari的异常情况下,选中了位于第一行的宽度为0的rect去构成Bounds,因此产生了这个计算错误。如图勾选的字符产生了两个红色rect,其中第一个只是一条线,但是html2canvas默认第一个即文字位置,因此位置错误,如绿色那样。
实际讨论与修复
首先我们需要判断的是,按照html2canvas设计思路,一个独立的分词/一个字符渲染时只有一个rect是否合理。这里参考的常用文字,与各种表情包会使用的超长/超界限的特殊字符,主观结论是:合理。
同时我们也可以认为safari输出了多余的空白rect是没有必要的行为,可能是缺陷(但是实测从iOS10到15的移动端safari均有这个问题)。
同时Bounds的构造方法:fromDOMRectList(context: Context, domRectList: DOMRectList): Bounds,这里输入DOMRectList会让人产生误解即Bounds可以处理多个DOMRect并进行渲染——事实上只是取了第一个。
因此修复方案与结论是:
1. 该问题是safari问题,以兼容为主:对于该场景输出的DOMRectList,只取第一个非0的DOMRect,并且在实际输出多个非0的DOMRect时认为不符合预期,发出线下异常。
2. Bounds对象合理性上,构造器改为输入当个DOMRect,选取DOMRect工作由使用场景去选择。
实际的修复结果在我遇到问题的场景是完全符合预期的,也能解决issues上的问题。不过实际进行极端测试的时候,同时还有个别分词上的问题,由于过于极端,暂时略过。
getClientRects的问题
回到getClientRects,虽然我们说对于一个分词/一个字符只返回一个DOMRect主观判断是合理的,但是从标准上是合理的么?查询mdn(https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getClientRects)与更有历史的http://help.dottoro.com/ljxsdaqk.php文档,我们注意到事实上提过这个:
When the width of a block element is specified, the returned TextRectangles collection contains only one TextRectangle object in Internet Explorer.
//&
起初,微软打算让这个方法给文本的每一行都返回一个TextRectangle,但是,CSSOM工作草案规定它应该给每个边框返回一个ClientRect。因此,对于行内元素这两个定义是相同的,但是对于块级元素,Mozilla只会返回一个矩形。(译者注:对于行内元素,元素内部的每一行都会有一个边框;对于块级元素,如果里面没有其他元素,一整块元素只有一个边框)。
那么我们的预期是:
- 正常情况下,block元素均只有一个rect。
- 对于inline元素,每一行有一个rect。但是inline一个元素是否被认为跨行确实是未定义行为,我们确实无法预期未来一个分词或者一个字符可能会被夸行。
因此对于我们使用getClientRects的指导是:我们需要考虑一个渲染对象跨行即在多个rect中分割渲染的情况。对于html2canvas的case,即渲染一个目标的时候,它可以被渲染到多个rect中,并且可横行排列组合成最后图像。
同时,这两篇文档上也提到getClientRects的演变史上,在IE/火狐等旧浏览器上均出现过各种需要兼容的异常行为。如果考虑支持较为旧的浏览器,这个方法坑是真多。github上有人做兼容库:https://github.com/edg2s/rangefix
它修复了包括:
Chrome <= 54: Selections spanning multiple nodes return rectangles for all the parents of the endContainer. See https://code.google.com/p/chromium/issues/detail?id=324437.
Chrome 55: Images get no rectangle when they are wrapped in a node and you select across them.
Safari: Similar to the Chrome <= 54 bug, but only triggered near the edge of a block node, or programmatically near an inline node.
Firefox: Similar to the Chrome <= 54 bug, but only triggered near the edge of a inline node
IE <= 10: Rectangles are incorrectly scaled when using the browser's zoom feature.
不过这个库确实没有包含本次遇到的问题,同时目前看更新不频繁使用的人也不多,估计是这个方法确实本身使用的人也不多。实际使用情况下,重依赖getClientRects的代码,包括html2canvas使用并维护一个兼容库确实是一个更合理的选择。