狗屁通给我讲的复杂文本排版居然和我想的一样

文本排版需要自动换行,所以最好在一个地方处理。但是我不确定这样是否“标准”,于是在我刚开始修改了一点点实现的时候我就问了狗屁通,这一通回答下,我感觉自信满满。

但是凭心而论,这个排版方式其实更应该适用于所有的内联排版(比如图文混排),而不仅仅是文本。然后,图片其实也就是个“不会wrap的text”仅此而已。所以后面如果要增加图文混排,只会更简单?

另外,排版这个过程应该由<text>的父元素来执行(即便<text>内还有其它元素),因为:对于来换行的父元素来说,<text是没法知道父元素的总体大小的。

我的文本节点是 <text>,它里面可以有 <b><i><code> 等子元素,而且子元素可以嵌套。由于文本在渲染、排版的时候是可以自动换行的,我应该如何表示这层关系、并且方便渲染?

我看了你的代码,我觉得现在最大的瓶颈不是 <b> 怎么表示,而是你的 Text 节点承担了两种职责

  1. DOM 节点(树)
  2. 排版对象(Layout)

浏览器实际上把这两者分开了。


浏览器大概是这样

DOM:

1
2
3
4
5
6
7
text
├── "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
4
type 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

例如:

1
Hello World !!! Bye

然后:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Segment()


Line1


Line2


...

这里可以跨 Run。

例如:

1
2
3
4
5
6
7
8
Run1

Hello

Run2

World

宽度:

1
Hello Wo

第一行:

1
Hello Wo

第二行:

1
rld

甚至:

1
Hel

也是合法的。

所以:

换行单位不是 Run,而是 Glyph。


第三步:生成 Line

例如:

1
2
3
type TextLine struct {
    Runs []TextRunFragment
}

其中:

1
2
3
4
5
type TextRunFragment struct {
    Run   *TextRun
    Start int
    End   int
}

例如:

1
Hello World!!!

可能得到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Line1

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
13
for _, 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
7
type Text struct {
    BaseBox

    Data string

    face FontFace
}

改掉。

Data 不应该存在。

应该:

1
2
3
4
5
type Text struct {
    BaseBox

    Lines []TextLine
}

真正的数据来自:

1
Children

例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Text


Children


TextNode

Bold

Italic

然后新增一种:

1
2
3
4
5
6
type TextData struct {

    BaseBox

    Data string
}

注意:

现在:

1
Text

不再保存:

1
Data

而是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Text


TextData


Bold


Italic

这样:

1
2
3
4
5
6
<text>
    Hello
    <b>
        World
    </b>
</text>

就是:

1
2
3
4
5
6
7
Text

├──TextData("Hello")

└──Bold

    └──TextData("World")

Calc()

Calc 时:

第一步:

DFS。

把树:

1
2
3
4
5
6
7
8
9
Text


Bold


Italic

展开:

1
[]TextRun

每个 Run 带:

1
最终 Styles

例如:

1
2
3
4
5
Bold


Style.FontBold=true

Italic:

1
2
3
FontBold=true

FontItalic=true

全部继承好了。


然后:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Runs


Layout


Lines


保存到

Text.Lines

Draw:

完全不用递归。

直接:

1
2
3
4
5
for 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
9
Draw()


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>、超链接等都不用推翻整个设计。

陪她去流浪 碎碎念 桃子