深入css的inline排版渲染原理

614 阅读10分钟

inline布局是css的重要特色之一,它在文档流中对文本排版起了不可或缺的重要作用。

前言

对于web来说,CSS的文本排版功能异常强大,外加块级、弹性等控制,形成了如今丰富的web展现。这很容易理解,因为HTML协议本身就是以“超文本”为基础的。 但web的绘图、矢量、动画等能力太过欠缺,这对RIA来说却是强项。反过来同样如此,RIA在文本排版上又弱得可怜。 正是由于二者之间侧重不同,导致了如今割裂的局面。canvas/svg的出现稍稍弥补了web的一些不足,但由于仍然是独立的渲染环境,所以这个割裂并未得到有效改进。

场景

inline的使用场景太过常见,以至于我们甚至不会去关注它,甚至认为是理所当然的。 随便打开一个能看见的APP上的卡片样式,如下图:

image.png

中间支付宝特色1500元提现免费额度就是inline的常见用途。当多个标签内容同处一行时,仍然视作同行排版,在抵达边缘处正常换行,且上下边距无效。 它可能的HTML结构是:

<div>
  <small>支付宝特色</small>
  <span>1500元提现免费额度</span>
</div>

说到这似乎没体现出inline的特殊之处,那么和inline-block/block对比一下:

<div>
  <small style="display:inline-block">支付宝特色</small>
  <span style="display:inline-block">1500元提现免费额度</span>
</div>

image.png

可以看到,无论block还是inline-block,2个行内元素small和span各自分离换行了。因为只要带上了block字样,css的盒子模型中这个元素都会尝试独占一行,即便是inline-block。那么什么情况下inline-block不换行呢?除非span内的文字很少达不到换行的要求:

<div>
  <small style="display:inline-block">支付宝特色</small>
  <span style="display:inline-block">提现免费</span>
</div>

而一旦文字过多,span元素又会表现得跟block一样。这便是inline-block的特性之一,介于inline和block之间的一种特殊盒子模型。block不用多说,怎么着都不会同行。

布局

上面的情况在HTML/CSS中是司空见惯的,可如果在canvas/svg这种RIA场景下出现该如何是好?往往我们会下意识屏蔽掉这种场景,产品和视觉也会自动认为RIA下不应该有这些东西,即便有也要独立提取出来放在HTML中。这是一个很神奇的思维定式,似乎它理所应当不应该出现在RIA中。这无疑限制了我们的想象力。

RIA中对于简单block的实现(暂不考虑盒子模型、bfc等)还是比较简单的,一个矩形盒子指定x/y/width/height即可。多个block出现则按文档流规则从上到下依次排列,y坐标根据前一个的y+height即可算出。如果有边距(margin/padding),再加上也很简单(不考虑margin合并)。

可一旦block不定高,即根据内容决定高度,自适应撑开,不少RIA就缺少这个能力了。想要解决需要费些功夫。

canvas.measureText(String).width;
window.getComputedStyle(dom, null).width;

上面2个api分别是canvas/svg下的测量文字宽度的办法,其中svg借助了HTML的能力。这样便可获取到每个字符的宽度。 让我们简化下举例:

image.png

红色边框是父层范围(或浏览器窗口范围),文字逐个(假设阅读模式为水平方向从左到右)排列,当第一行“1500元提现”排完时,后面的“免”字尝试放置发现宽度不够了,于是发生换行,y坐标增加lineHeight计算值后重新x从0开始。最终这个元素的尺寸为蓝色背景区域,于是不定高元素的height便计算出来了。下个block继续往下排列。

扩展话题:canvas/svg无法像浏览器那样直接拿到系统字体信息,因此测量存在误差,详见:stackoverflow.com/questions/6… 即便有解决办法,但这个办法依旧存在少量特殊情况的误差。如Arial的字符f就比字符1需要个额外的误差匹配比例配置。

回到inline的情况,仍旧简化下举例:

image.png

当2个inline出现的情况,第1个没占满,第2个可以前面一部分跟着首行但后面另一部分会换行。此时多加个x坐标记录,使得有2个x坐标:首行x和换行后行首lx。这样前面一部分可以紧跟着x继续,“提”字放得下,但“现免费”放不下,因此重新从lx开始。多行的情况后面都是lx,只有首行如此。 完成后,span元素的整体占位是蓝色背景区域,这看起来比“提现免费”4个字要大,因为是以其包含的LineBox极值计算的。最小的x在“现”的左侧,最小的y在“提”的上侧,最大的x在“提”的右侧,最大的y在“现免费”的下侧。

另外inline的mpb(margin/padding/border简称,下同)在水平方向是生效且影响布局的,并且只在首尾生效,因此假如出现了水平mpb且导致“提”无法紧跟着首行的话,会出现下图情况(黄色背景标明水平mpb):

image.png

如果水平mpb算上仍然首行能排下的话,就会出现左侧mpb在第1行,右侧mpb在第2行的情况:

image.png

还有比较特殊的情况是,每行至少要有一个字符,即便宽度不足,防止排不下死循环的情况。这种最少字符限制在其它布局场景中也经常能见到(如flex),在此不再赘述。

嵌套

之前都是简化的场景,如果inline包含inline/inline-block怎么办? inline的递归inline表现没什么不同,其共享顶层inline的换行规则,即x和lx坐标是公用的,LineBox的产生和从属也是公用的。 inline-block则不同,其内部生成了新的块级上下文,当发生换行时x和lx坐标、LineBox也完全和外界隔离开来。

image.png

上图这个例子假设左边small是inline,右边span是个inline-block且产生了换行。想要达到这种效果是不是少了些什么?没错之前说过,inline-block会试图占满整行,如果发生换行应该是下图的情况:

image.png

如果依旧想要不换行的话,只能设置inline-block的width为固定宽度且不能大于首行剩余空白尺寸。 可以看到,inline-block内部换行后的lx是从自己的边界开始计算的,由于其内部只有1个inline且无水平mpb,所以x和lx坐标相同。最终产生了2个LineBox,如下图2个蓝色区域所示(暂不考虑vertial-align对齐):

image.png

如果inline-block内部再包含多个inline,递归行为又重新开始,回到本节开头处。

LineBox

说了这么多,LineBox也经常听到,那它到底是什么?这里有篇译文很好地介绍了它和字体的知识:zhuanlan.zhihu.com/p/25808995 说白了就是一行内容所在矩形区域。当inline发生换行时就会产生一个新的LineBox,一个上下文节点内部会有若干个LineBox(0个为空白节点、1个是只有1行、多个是换行)。块级元素(block、inline-block、flex等)会产生新的上下文,inline和文本会复用这个上下文,而LineBox中会包含文本块、inline-block、特殊的inline节点(如img标签)。

  • 文本块:处在这一行的文本。如果一段文本超长产生换行,则截取其中处在本行的文本。
  • inline-block:行内块级元素,只有在不导致换行的情况下(见上节)会和其它内容共处于同个LineBox内。
  • 特殊inline节点:img标签等,其可以设置width/height。 注意没提到LineBox内部包含inline本身,因为复用上下文的缘故,且其真正参与布局影响的是它的内容(文本),所以算法实现上可以认为LineBox内部没有inline。

垂直对齐

之前暂不考虑的vertial-align对齐,在完成基础布局后要处理一下。以下图2种情况举例:

image.png

图中左侧是2个inline同行但由于font不相同(字体、大小、行高等),基于默认对齐方式是baseLine的情况,2个inline文本是底部对齐(这里中文底部恰好是小写字母x的底部)。 图中右侧右边是个定宽inline-block,且产生了2个LineBox。此时对齐方式是inline的baseLine和inline-block的最后一个LineBox的baseLine对齐。 渲染 前面说过,inline的水平mpb影响布局且只出现在首尾(多行情况),垂直mpb无效。但是在渲染时,垂直mpb有效且会应用在每一行中,这就是inline的渲染特殊之处:根据LineBox进行渲染,而非节点本身。

<div style="font-family:Arial;word-break:break-all;width:100px;background:#CCC;">
  <span style="font-size:50px;background:rgba(255,0,0,0.3)">2222</span>
</div>

image.png

可以看到,因为换行产生了2个LineBox,所以背景色也有2块。需要特殊说明的是,有的字体会观察不出来分割的效果,那是因为字体没有lineGap,常见字体中Arial才行,通过软件我们也能确认Arial的行缝隙信息:

image.png

mpb情况:

<div style="word-break:break-all;width:40px;background:#CCC;">
  <span style="padding:2px;border:2px solid rgba(0,0,255,0.5);font-size:30px;background:rgba(255,0,0,0.3)">222222</span>
</div>

image.png

可以看到水平mpb只在首尾行出现,垂直mpb渲染是每行都生效的,由于值相较行间距大,所以产生了重叠的情况。 奇怪的是,在声明background-clip为content-box时,chrome/blink(v89)出现了渲染错误的情况,safari/webkit和firefox/gecko则正确。karas的实现也规避了这个bug。

image.png

左边错误渲染,右边正确。

上面渲染只是纯色,当遇到background-image(包含图片和渐变)时还要特殊一些。

image.png

假设用karas的logo作为一个多行inline的背景图会怎样,先看渲染结果:

图中左侧是最终情况,看着比较乱,实际假如inline不换行的话,右侧是最终情况。换行对于不换行而言,就是先按不换行情况渲染,然后进行切割。 在canvas中,切割比较容易做到,svg麻烦点,生成可复用的包含image的symbol后,引用时再用clipPath切割它。 差异 当有不同font(字体、大小、行高)同LineBox,渲染会发生什么情况?

<div style="word-break:break-all;width:80px;background:#CCC;">
  <span style="background:rgba(255,0,0,0.3);">22<strong style="padding:5px;font-size:50px;background:rgba(0,0,255,0.3)">3</strong>22</span>
</div>

image.png

这个LineBox比较特殊,中间的嵌套inline的strong包含的3字体很大,但是外边的span并不会按照LineBox的最大范围渲染,它的y尺寸依旧是本身的lineHeight计算值。即便strong是个空节点也会如此。

<div style="margin:10px;word-break:break-all;width:80px;background:#CCC;">
  <span style="background:rgba(255,0,0,0.3);">22<strong style="padding:5px;font-size:50px;background:rgba(0,0,255,0.3)"></strong>22</span>
</div>
<div style="margin:10px;word-break:break-all;width:80px;background:#CCC;">
  <span style="background:rgba(255,0,0,0.3);">22<strong style="font-size:50px;"></strong>22</span>
</div>

image.png

最后看下karas在v0.54版本实现inline的pr情况,大概花了几天分析设计,10天编码,一共2周的时间:github.com/karasjs/kar… code大头反而是test。 注:由于简化的缘故,karas并未实现LineBox序渲染,仍按照dom节点序渲染。这会发生在嵌套inline且多行时,外部inline无法覆盖内部inline上一行的LineBox。它实在过于罕见,所以省略了。