狗屁通给我讲的复杂文本排版居然和我想的一样
文本排版需要自动换行,所以最好在一个地方处理。但是我不确定这样是否“标准”,于是在我刚开始修改了一点点实现的时候我就问了狗屁通,这一通回答下,我感觉自信满满。
但是凭心而论,这个排版方式其实更应该适用于所有的内联排版(比如图文混排),而不仅仅是文本。然后,图片其实也就是个“不会wrap的text”仅此而已。所以后面如果要增加图文混排,只会更简单?
另外,排版这个过程应该由<text>的父元素来执行(即便<text>内还有其它元素),因为:对于来换行的父元素来说,<text是没法知道父元素的总体大小的。
我的文本节点是
<text>,它里面可以有<b>、<i>、<code>等子元素,而且子元素可以嵌套。由于文本在渲染、排版的时候是可以自动换行的,我应该如何表示这层关系、并且方便渲染?
我看了你的代码,我觉得现在最大的瓶颈不是
<b>怎么表示,而是你的Text节点承担了两种职责:
- DOM 节点(树)
- 排版对象(Layout)
浏览器实际上把这两者分开了。
浏览器大概是这样
DOM:
1 2 3 4 5 6 7text ├── "Hello " ├── b │ ├── "World " │ └── i │ └── "!!!" └── " Bye"对应:
1 2 3 4 5 6 7 8<text> Hello <b> World <i>!!!</i> </b> Bye </text>但是 Layout 并不会递归画这个树。
而是先展开。
第一步:生成 TextRun
浏览器会得到类似:
1 2 3 4type TextRun struct { Text string Style Styles }例如:
1 2 3 4 5 6[ {Text:"Hello ", Style:normal}, {Text:"World ", Style:bold}, {Text:"!!!", Style:bold+italic}, {Text:" Bye", Style:normal}, ]注意:
已经没有 DOM。
只有:
1 2 3 4 5字符串 + 最终样式
第二步:Layout
Layout 输入:
1[]TextRun例如:
1Hello World !!! Bye然后:
1 2 3 4 5 6 7 8 9 10 11 12 13Segment() ↓ Line1 ↓ Line2 ↓ ...这里可以跨 Run。
例如:
1 2 3 4 5 6 7 8Run1 Hello Run2 World宽度:
1Hello Wo第一行:
1Hello Wo第二行:
1rld甚至:
1Hel也是合法的。
所以:
换行单位不是 Run,而是 Glyph。
第三步:生成 Line
例如:
1 2 3type TextLine struct { Runs []TextRunFragment }其中:
1 2 3 4 5type TextRunFragment struct { Run *TextRun Start int End int }例如:
1Hello World!!!可能得到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21Line1 Hello Wo ↓ [ normal(Hello ), bold(Wo), ] Line2 rld!!! ↓ [ bold(rld), bold+italic(!!!), ]
Draw
Draw 就非常简单:
1 2 3 4 5 6 7 8 9 10 11 12 13for _, line := range lines { x := 0 for _, run := range line.Runs { face := fontManager.Face(run.Style) DrawString(run.Text) x += run.Width } }
对你的代码
我会把:
1 2 3 4 5 6 7type Text struct { BaseBox Data string face FontFace }改掉。
Data 不应该存在。
应该:
1 2 3 4 5type Text struct { BaseBox Lines []TextLine }真正的数据来自:
1Children例如:
1 2 3 4 5 6 7 8 9 10 11 12 13Text ↓ Children ↓ TextNode Bold Italic
然后新增一种:
1 2 3 4 5 6type TextData struct { BaseBox Data string }注意:
现在:
1Text不再保存:
1Data而是:
1 2 3 4 5 6 7 8 9 10 11 12 13Text ↓ TextData ↓ Bold ↓ Italic这样:
1 2 3 4 5 6<text> Hello <b> World </b> </text>就是:
1 2 3 4 5 6 7Text ├──TextData("Hello") └──Bold └──TextData("World")
Calc()
Calc 时:
第一步:
DFS。
把树:
1 2 3 4 5 6 7 8 9Text ↓ Bold ↓ Italic展开:
1[]TextRun每个 Run 带:
1最终 Styles例如:
1 2 3 4 5Bold ↓ Style.FontBold=trueItalic:
1 2 3FontBold=true FontItalic=true全部继承好了。
然后:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15Runs ↓ Layout ↓ Lines ↓ 保存到 Text.Lines
Draw:
完全不用递归。
直接:
1 2 3 4 5for line for run Draw
我为什么推荐这种结构
因为以后你肯定还会支持:
1 2 3 4 5<b> <i> <code> <span> <color>甚至:
1<a>如果每一种都在 Draw 里面递归:
1 2 3 4 5 6 7 8 9Draw() ↓ Draw() ↓ Draw()最后:
1 2 3 4 5 6 7 8 9自动换行 跨节点 选中 复制 光标都会变得非常痛苦。
而浏览器几十年来一直都是:
DOM → TextRun → Line → Glyph → Draw
DOM 只是输入。
真正负责排版的是一组已经扁平化的 TextRun。
结合你现在的代码,我建议把
Text从“叶子节点”改成“文本容器”,新增一个真正的文本叶子节点(例如你已经有的_TextData,把它完善即可)。 然后在Text.Calc()中递归遍历子树,继承样式并生成[]TextRun,再进行换行和排版。这一步完成后,Draw()就完全不需要关心 DOM 树了,只负责按排版结果绘制。这种结构和浏览器、Qt、Flutter 的文本引擎都非常接近,而且以后加<i>、<code>、<span>、超链接等都不用推翻整个设计。