canvas文本绘制自动换行、字间距、竖排等实现

这篇文章发布于 2018年02月5日,星期一,02:57,归类于 Canvas相关。 阅读 94062 次, 今日 11 次 28 条评论

 

一、canvas对文字排版的支持很弱

和CSS相比,SVG以及canvas对文字排版的支持很弱。

在CSS中天然支持的文本自动换行,其他letter-sapcing字间距,writing-mode竖排等都是一个CSS属性就可以实现。但是在canvas中,全部都不支持。

canvas绘制文本API为:

CanvasRenderingContext2D.fillText(text, x, y [, maxWidth]);
text
text是需要绘制的文本。
x
x是文本绘制的水平参考点坐标。随着CanvasRenderingContext2D.textAlign的设置不同,x的坐标位置也不同。可以表示这段文字内容左侧坐标,或水平中心坐标,或右侧坐标。
y
y是文本绘制的垂直参考点坐标。随着CanvasRenderingContext2D.textBaseline的设置不同,y的坐标位置也不同。支持多种基线类型(CSS中也有对应概念),MDN上有一张图可以很好地表示文本基线和文本垂直位置的关系。

canvas文本绘制基线和对齐示意

maxWidth
maxWidth表示文本内容占据的最大宽度。这里的maxWidth概念和CSS中的max-width差别很大,其最终的文本表现是:当文本占据宽度超过maxWidth的后,所有的文本自动变窄以适应这个最大宽度限制。表现类似这样:

maxWidth限制的文本

您可以狠狠地点击这里:maxWidth参数让文字变窄demo

相关测试代码如下:

var canvas = document.querySelector('canvas');
var context = canvas.getContext('2d');
context.font = '32px sans-serif';
context.fillText('我是一段被maxWidth限制的文本', 0, 50, 200

如果没有maxWidth限制,则文本会一行走到底,直到超出画布尺寸,有点类似CSS中设置容器white-space:nowrap + overflow:hidden的表现。

二、如何让canvas支持自动换行?

如何让canvas支持自动换行?

首先有一点可以肯定,就是到目前为止,canvas中并没有任何可以让文本自动换行的现成的API。

因此注定这个看上去简单的事情实践起来并没有那么容易。

通常比较好的实现方法有下面两种:

1. canvas计算与逐行绘制

实现原理的核心是CanvasRenderingContext2D.measureText(text)这个API,可以返回一个TextMetrics对象,其中包含了当前上下文环境下text double精度的占据宽度,于是我们就可以通过每个字符宽度的不断累加,精确计算哪个位置应该可以换行。

下面就是我扩展的文本自动换行方法JS代码:

CanvasRenderingContext2D.prototype.wrapText = function (text, x, y, maxWidth, lineHeight) {
    if (typeof text != 'string' || typeof x != 'number' || typeof y != 'number') {
        return;
    }
    
    var context = this;
    var canvas = context.canvas;
    
    if (typeof maxWidth == 'undefined') {
        maxWidth = (canvas && canvas.width) || 300;
    }
    if (typeof lineHeight == 'undefined') {
        lineHeight = (canvas && parseInt(window.getComputedStyle(canvas).lineHeight)) || parseInt(window.getComputedStyle(document.body).lineHeight);
    }
    
    // 字符分隔为数组
    var arrText = text.split('');
    var line = '';
    
    for (var n = 0; n < arrText.length; n++) {
        var testLine = line + arrText[n];
        var metrics = context.measureText(testLine);
        var testWidth = metrics.width;
        if (testWidth > maxWidth && n > 0) {
            context.fillText(line, x, y);
            line = arrText[n];
            y += lineHeight;
        } else {
            line = testLine;
        }
    }
    context.fillText(line, x, y);
};

API如下:

CanvasRenderingContext2D.wrapText(text, x, y, maxWidth, lineHeight)

其中text, x, y 3个参数和fillText()方法中的这3个参数含义是一样的,不赘述。
maxWidth表示的含义可就不一样了,表示最大需要换行的宽度,此参数可缺省,默认会使用canvas画布的width宽度作为maxWidthlineHeight表示行高,同样可缺省,默认会使用<canvas>元素在DOM中继承的line-height作为行高。

使用示例如下:

var canvas = document.querySelector('canvas');
var context = canvas.getContext('2d');
context.font = '16px sans-serif';
context.textBaseline = 'top';
context.wrapText('我是一段会换行的文字啦啦啦', 0, 0);

用法很简单,使用wrapText代替原生的fillText即可!

您可以狠狠的点击这里:自动换行扩展API wrapText演示demo

下面截图就是demo页面绘制效果(截自IE9浏览器):

自动换行效果示意

可以看到上方绘制的文字在核实位置自动换行了,您可以修改<textarea>中的文字内容,点击“绘制”按钮体验下其他文本内容的自动换行绘制效果。

2. 借助SVG <foreignObject>直接把CSS效果绘制上去

关于SVG <foreignObject>让HTML转换成canvas图片的原理和细节可以参见我之前写的“SVG <foreignObject<简介与截图等应用”这篇文章。

基本上,本文后面会介绍到的字符间距,文字竖排等实现都可以使用这个方法实现,因此,为了避免不必要的啰嗦,仅本效果会具体演示代码细节,后面效果大家自行拷贝改改就好了。

我们先看实例,您可以狠狠地点击这里:canvas借助SVG foreignObject实现文本自动换行demo

结果Chrome浏览器下:

Chrome下的canvas自动换行

JS实现如下:

var canvas = document.querySelector('canvas');
var context = canvas.getContext('2d');
context.font = '16px sans-serif';
var width = canvas.width;
var height = canvas.height;

var tempImg = new Image();
tempImg.width = width;
tempImg.height = height;
tempImg.onload = function () {
    // 把img绘制在canvas画布上
    context.drawImage(this, 0, 0, width, height);
};
tempImg.src = 'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="'+ width +'" height="'+ height +'"><body xmlns="http://www.w3.org/1999/xhtml" style="margin:0;font:'+ context.font +';">我是一段需要换行的文字啦啦啦</body></foreignObject></svg>';

此方法优点在于足够简单,只要一段带style样式的HTML代码即可!

唯一不足在于兼容性,IE浏览器不支持<foreignObject>,最新的Firefox浏览器虽然支持<foreignObject>,但是只能以<img<形式呈现,无法绘制到canvas画布上(若谁知道原因欢迎不吝赐教)。

不过好的是移动端Safari浏览器以及微信浏览器都是支持的,因此,此方法理论上是可以在移动端使用的。例如我手机Safari的效果截图:

Safari浏览器下的自动换行实现

二、如何让canvas支持字符间距?

1. 如果只需要兼容Chrome,直接letter-spacing控制

对于Chrome浏览器,无论是字符间距还是单词间距,都可以自动继承于<canvas>元素,这个特性让人非常感动。

也就是:

canvas { letter-pacing: 5px; }

绘制的文字字符间距自动就是5px

如此欣喜的特性有必要亲眼见证一下,您可以狠狠地点击这里:canavs文本间距使用CSS letter-spacing实现demo

效果如下GIF示意:

直接CSS letter-spacing控制canvas字符间距

完整测试JS代码如下:

var canvas = document.querySelector('canvas');
var context = canvas.getContext('2d');
var range = document.querySelector('input[type=range]');
// 绘制方法
var draw = function () {
    // 清除之前的绘制
    context.clearRect(0, 0, canvas.width, canvas.height);
    // 字符间距设置
    canvas.style.letterSpacing = range.value + 'px';
    // 并绘制文本,font属性值设置一定要在这里
    context.font = '32px sans-serif';
    context.fillText('我是一段文本', 0, 50);
};
// 改变字符间距后重绘
range.addEventListener('change', draw);
// 一进来根据默认值绘制
draw();

根据我的观察,貌似Chrome浏览器在设置font属性值的时候,把letter-spacing等信息一起算作上下文中了。所以,虽然看上去context.font = '32px sans-serif'一直都没变,但却不能放在draw()方法之外,否则,还是按照老的letter-spacing渲染而看不到字符间距变化。

此方法最简单最容易理解,只可惜,根据我的测试,目前仅Chrome浏览器支持。Firefox以及Safari全都不行。

2. canvas计算与逐字绘制

原理为,每一个字符单独作为一个绘制单元,然后根据字符宽度+letterSpacing间距动态绘制,同样,离不开使用CanvasRenderingContext2D.measureText(text)这个API。

以下就是自己直接在原型上扩展的字符间距绘制方法letterSpacingText,大家可以直接拷贝过去使用,MIT协议,保留原出处即可。

/**
* @author zhangxinxu(.com)
* @licence MIT
* @description http://www.zhangxinxu.com/wordpress/?p=7362
*/
CanvasRenderingContext2D.prototype.letterSpacingText = function (text, x, y, letterSpacing) {
    var context = this;
    var canvas = context.canvas;
    
    if (!letterSpacing && canvas) {
        letterSpacing = parseFloat(window.getComputedStyle(canvas).letterSpacing);
    }
    if (!letterSpacing) {
        return this.fillText(text, x, y);
    }
    
    var arrText = text.split('');
    var align = context.textAlign || 'left';
    
    // 这里仅考虑水平排列
    var originWidth = context.measureText(text).width;
    // 应用letterSpacing占据宽度
    var actualWidth = originWidth + letterSpacing * (arrText.length - 1);
    // 根据水平对齐方式确定第一个字符的坐标
    if (align == 'center') {
        x = x - actualWidth / 2;
    } else if (align == 'right') {
        x = x - actualWidth;
    }
    
    // 临时修改为文本左对齐
    context.textAlign = 'left';
    // 开始逐字绘制
    arrText.forEach(function (letter) {
        var letterWidth = context.measureText(letter).width;
        context.fillText(letter, x, y);
        // 确定下一个字符的横坐标
        x = x + letterWidth + letterSpacing;
    });
    // 对齐方式还原
    context.textAlign = align;
};

API详细:

CanvasRenderingContext2D.letterSpacingText(text, x, y, letterSpacing);

其中text, x, y 3个参数和fillText()方法中的这3个参数含义是一样的,不赘述。letterSpacing表示字符间距大小,数值。可缺省,默认会拿<canvas>元素在DOM环境下的letter-spacing大小作为计算值。

使用示意:

var canvas = document.querySelector('canvas');	
var context = canvas.getContext('2d');
context.font = '32px sans-serif';
context.textAlign = 'center';
// 字符间隙5px
context.letterSpacingText('我是一段文本', canvas.width / 2, 50, 5);

您可以狠狠地点击这里:canavs字符间距JS逐字计算demo

效果如下GIF示意(居中对齐,截自IE Edge):

居中对齐下的字符间隙示意

此方法兼容性非常好,IE9+浏览器都支持,PC和Mobile通吃。

3. 借助SVG <foreignObject>直接把CSS效果绘制上去

和自动换行实现类似,详细略。

四、如何让canvas支持竖直排列?

文字竖直排列,对于玩英文的老外,可以使用context.rotate()旋转90deg实现,但是对于中文等中亚文字,却是完全不适合的。因为两种语言的竖直排版规则是不一样的。

中文等东亚文字上,例如一些古诗词文字还是正的,仅仅是阅读方向是从上往下,但是,英文(以及阿拉伯数字)由于本身的字符特性,直接就是旋转排列的。

所以单纯旋转策略对于大中国并不实用。

在CSS中,我们可以使用writing-mode改变文档流的方向,从而实现文字竖排,相关文章可以参见我之前的文章:“改变CSS世界纵横规则的writing-mode属性”,或者购买我的《CSS世界》,其中有详细介绍。

那在canvas中又该如何实现呢?

1. JS混合计算逐字排列

混合计算规则如下:全角字符竖排,英文数字等半角字符旋转排列。

下面是我扩展的竖排方法,同样MIT协议,可随意使用,保留上面一段作者和出处说明即可。

/**
* @author zhangxinxu(.com)
* @licence MIT
* @description http://www.zhangxinxu.com/wordpress/?p=7362
*/
CanvasRenderingContext2D.prototype.fillTextVertical = function (text, x, y) {
    var context = this;
    var canvas = context.canvas;
    
    var arrText = text.split('');
    var arrWidth = arrText.map(function (letter) {
        return context.measureText(letter).width;
    });
    
    var align = context.textAlign;
    var baseline = context.textBaseline;
    
    if (align == 'left') {
        x = x + Math.max.apply(null, arrWidth) / 2;
    } else if (align == 'right') {
        x = x - Math.max.apply(null, arrWidth) / 2;
    }
    if (baseline == 'bottom' || baseline == 'alphabetic' || baseline == 'ideographic') {
        y = y - arrWidth[0] / 2;
    } else if (baseline == 'top' || baseline == 'hanging') {
        y = y + arrWidth[0] / 2;
    }
    
    context.textAlign = 'center';
    context.textBaseline = 'middle';
    
    // 开始逐字绘制
    arrText.forEach(function (letter, index) {
        // 确定下一个字符的纵坐标位置
        var letterWidth = arrWidth[index];
        // 是否需要旋转判断
        var code = letter.charCodeAt(0);
        if (code <= 256) {
            context.translate(x, y);
            // 英文字符,旋转90°
            context.rotate(90 * Math.PI / 180);
            context.translate(-x, -y);
        } else if (index > 0 && text.charCodeAt(index - 1) < 256) {
            // y修正
            y = y + arrWidth[index - 1] / 2;
        }
        context.fillText(letter, x, y);
        // 旋转坐标系还原成初始态
        context.setTransform(1, 0, 0, 1, 0, 0);
        // 确定下一个字符的纵坐标位置
        var letterWidth = arrWidth[index];
        y = y + letterWidth;
    });
    // 水平垂直对齐方式还原
    context.textAlign = align;
    context.textBaseline = baseline;
};

API名称是fillTextVertical,语法如下:

CanvasRenderingContext2D.fillTextVertical(text, x, y)

其中text, x, y 3个参数和fillText()方法中的这3个参数含义是一样的,不赘述。

实现的效果是:英文数字等旋转,中文垂直排列。支持textAligntextBaseline等基本设置。

您可以狠狠地点击这里:canavs文本竖排JS逐字计算实现demo

使用JS如下:

var canvas = document.querySelector('canvas');	
var context = canvas.getContext('2d');
context.font = '24px STKaiti, sans-serif';
context.textAlign = 'center';
context.textBaseline = 'top';
context.fillTextVertical('anglebaby和黄晓明', canvas.width / 2, 0);

结果如下图所示:

竖直排列效果截图

2. 借助SVG <foreignObject>直接把CSS效果绘制上去

和自动换行实现类似,详细略。

五、结束语

当年CSS之所以一统天下就是在文本展现文字排版这一块非常方便。

看看SVG的文本展现,在看看canvas的文本呈现,难用的很。全靠友军衬托啊!

问题来了,CSS文本呈现这里厉害,那还需要canvas干什么?

因为canvas可以方便把文字转换成图片,例如一些广告工具等等,需要前端合成的,就需要canvas大放异彩了。

本文扩展的这些方法并未实际项目大规模验证,有疏漏之处在所难免,欢迎指正!

感谢阅读!

(本篇完)

分享到:


发表评论(目前28 条评论)

  1. iocscix说道:

    wrapText中计算行高如果是normal不会报错吗

  2. 君之虞说道:

    如何让canvas支持显示数学公式啊….

  3. richard说道:

    赞呀,最近在做canvas文字排版的实现,很多想法不谋而合,点赞!!

  4. jinqi说道:

    求大老研究下根据曲线写文字的dome

  5. vue 有报错说道:

    CanvasRenderingContext2D.prototype.wrapText在vue项目中,有 (property) CanvasRenderingContext2D: {
    new (): CanvasRenderingContext2D;
    prototype: CanvasRenderingContext2D;
    },提示我应该是 CanvasRenderingContext2D,prototype,wrapText

  6. CodeHz说道:

    用按字绘制的方法的话,对于某些小众语言和排版系统的文字是非常不友好的。。。所以要么写个几千行代码去兼容,要么就换个实现。。。

    • Sai说道:

      是的,如果是阿文,就需要从右往左排版。但是direction有版本要求,不知道怎么做pollfill。。。

  7. 象跑跑说道:

    这个竖行文字排版在渲染非等宽字体的西文时是会错位的(和字体有关),如Winter这个单词,W和i之间的间隙会很大

  8. 萧萧说道:

    大神,我发现一个问题,ios上竖排的英文和数字会有一个很奇怪的问题,亲测,绘制发现是数字或者英文之后,绘制一个数字或字母就停止绘制了,pc和安卓都没问题

  9. 大螃蟹说道:

    文字换行好像有点问题;我在chrome上用 第一次渲染的时候第一行不起作用;再点换行才正常工作。 比如:最大宽度是300, 第一次渲染宽度铺满canvas,再渲染才正常换行

  10. 厉害说道:

    屌爆了,贴主,爱死你了,分享东西真棒,我要以身相许

  11. jeffwcx说道:

    // 水平垂直对齐方式还原
    context.textAlign = align;
    context.textBaseline = baseline;

    类似这样的还原操作,可以采用
    context.save();
    context.restore();

  12. zlw说道:

    foreignObject

    如果使用了这个,canvas很难再转化成图片了。被污染的canvas无法输出数据。被浏览器限制了。

  13. lemotw说道:

    你好:
    context.setTransform(1, 0, 0, 1, 0, 0)
    放在 fillText() 之前先行初始會表較好些,在某些情況下會使得第一個字跑版。

    然後感謝大大提供這解法

  14. 城管大队长说道:

    “最新的Firefox浏览器虽然支持,但是只能以形式呈现,无法绘制到canvas画布上”

    关于这个可以尝试一下,svg转为dataURI方式,作为imag中src的属性值。

  15. perfect说道:

    请问,在IE9及以上浏览器,canvas画图,宽度超过一定值,部分内容不显示,怎么处理?谷歌浏览器正常。使用excanvas在IE8浏览器也正常。

  16. 野变说道:

    绘制结果看起来不清楚~

  17. Handle说道:

    最近看到一个中文文字排版引擎,不知道靠谱不……感觉的话……还算……简单???
    https://github.com/Icemic/huozi.js

  18. mfk说道:

    那个wrapText方法中,计算换行宽度的,你的算法是一次加一个字符,然后反复尝试。
    用二分法(折半法)尝试,可能效率更好。

  19. hovis说道:

    谢谢老师分享
    http://hovis.cn

  20. 夏目贵志说道:

    大大我兴趣爱好 想学习 html+css

    你写的新作 是否适合零基础的人 去阅读学习呢?

    抱歉,打扰了!

  21. 天下第一说道:

    看到了沙发想坐一下

  22. Twinkleee说道:

    这个等了好久了,谢谢分享