嵌入式富文本控件的实现思路

本文的实现基于杰理SDK的字体引擎,相关基础知识见 杰理字体引擎:CJK判定、文字方向与自动换行

整体设计可以分为三个阶段:解析 → 排版 → 绘制

1. 解析

输入一段带标签的字符串:

"[c=0xF800]错误[/c] 连接失败\n状态:[c=0x07E0]正常[/c] [img=IconOK]"

解析器从头到尾扫一遍,识别出三种东西:

  1. 颜色标签 [c=颜色]...[/c]
  2. 图片标签 [img=名称]
  3. \n 强制换行

2. 排版

把结构化的数据摆成行:

RichText
  ├─ RichLine (第1行)  y=0, height=16
  │    ├─ RichRun (文字"错误", 红色)
  │    ├─ RichRun (文字" 连接失败")
  │
  ├─ RichLine (第2行)  y=18, height=16
  │    ├─ RichRun (文字"状态:")
  │    ├─ RichRun (文字"正常", 绿色)
  │    ├─ RichRun (图片 IconOK)
  ...

3. 绘制

每帧绘制的时候,遍历所有Line和Run:

对每一行:
  这行在屏幕可见区域内吗?
    ├─ 不在 → 跳过
    └─ 在   → 对这行的每个 Run:
               ├─ 文字 Run → 把位图用 GPU 画到屏幕上,叠加 Run 的颜色
               └─ 图片 Run → 把图片资源画到屏幕上

解析+排版的实现算法

上面是总览,下面展开前两个阶段的实现——解析和排版其实是在同一次扫描中完成的。

核心思路:一个指针从头扫到尾,边扫边完成解析和排版。

算法需要维护两个指针和一个状态:

  • *p 当前扫描位置
  • *segStart 当前文字段起点
  • curColor 当前颜色

然后逐字符扫描,逻辑很简单:遇到特殊字符就处理,没遇到就继续扫。

if 扫描到 *p = '[', 就找到后面的 ]
{
    // 不管是什么标签,先把 segStart 到 p 之间的文字吐出去
    if (p > segStart)
    {
        把这段文字保存为 Run(用 curColor 着色)
    }

    if 是颜色开始标签
    {
        curColor = 解析出的颜色
    }
    if 是颜色结束标签
    {
        curColor = 默认颜色
    }
    if 是图片标签
    {
        追加一个图片 Run
        // 遇到图片 Run,curColor 不会重置,所以 color 标签里可以嵌套 img 标签
    }

    p 移动到 ] 后面
    segStart = p    // 重新开始攒下一段文字
}
else
{
    p++    // 普通字符,继续攒
}

那问题来了,这个逻辑,只有解析,没有排版。排版是怎么实现的呢?

答案是:排版就藏在"把文字保存为Run"的这个动作里。

这个动作内部会执行:

  1. 扫描文字,遇到 \n 就切开,把文字分成若干段
  2. 测量这段文字的宽度和高度
    • 放得下?→ 直接追加
    • 放不下?→ 找断点,断点前放当前行,新建一行,后半继续循环
  3. 创建 RichRun,设置 x = 当前行前面元素的宽度
  4. 更新行宽、行高

这有点分治思想的意思:

"第一行文字\n很长的第二行需要自动断行的文字"
       │
   先按 \n 切成两段
       │
  ┌────┴────┐
"第一行文字"  "很长的第二行需要自动断行的文字"
  │              │
  直接追加        进入自动断行循环