cover

Telegram 的富文本渲染机制浅析

sorcererxw

最近在试着调用 Telegram 的 TDLib API 拉取数据,然后在网页上渲染展示。分析下 Telegram 富文本数据的数据结构与渲染方案。

通过 API 可以获取如下的数据结构:

{
  "content": "It is a test sentence.",
  "entities": [
    {
      "type": "bold",
      "offset": 3,
      "length": 2
    },
    {
      "type": "italic",
      "offset": 0,
      "length": 7
    },
    {
      "type": "url",
      "offset": 13,
      "length": 8,
      "url": "https://www.google.com"
    },
    {
      "type": "strikethrough",
      "offset": 10,
      "length": 5
    }
  ]
}

其实就是对应下面这一段富文本:

🕶️
It is a test sentence.

如何生成

可以看到,telegram 的富文本就是基于一段 plain text(content),然后在上面不断叠加一层一层 layer(entity)来实现的。每一个 entity 当中包含了格式类型,文本开始位置和长度,以及当前格式的一些附加信息。

  • 在用户编辑文本的时候,选中一段文本,并施加相应的格式,就往 eneities 当中增加相应的 entity。
  • 如果用户取消某一段文本的格式,就需要遍历所有 entities,找到符合对应格式的 entity,如果恰好包含所修改的区间,则需要将当前 entity 拆分为一个或两个只 entity,以剔除中间重复的区域。

如何渲染

看到这种模式,最自觉想到的方案就是,一层一层 entity 通过 replace 去还远到原始文本中。但很快就会发现这样行不通:通过文本替换,势必破坏原来的 offset+length 的位置关系,当要处理多个 entity 的时候出现偏移。

那么不如换个思路,一整段话 + 一组 entity,不同类型 entity 互相叠加排列组合,会出现非常多的格式,比如某些文本是 bold+italic,有些是 underline+bold+url 等等。但是无论如何,必然存在一个子串内,所有文本的格式是一样。那么,基于这个设定,我们可以将整个段落打散,生成一组原子子串,每一个子串内所有文本拥有相同的格式。

至于如何打算,只需要取所有 entity 两侧的端点,作为分割点即可:

points = entities
   .map(e=>[e.offset,e.offset+e.length])
   .flatten()
   .uniq()
   .sort()

比如上文的例子,这样一来我们就能得到一组风格点: [0,3,5,7,10,13,15,21]

使用这些分割点对原始文本进行分割,就可以得到一组子串:["it ","is"," a"," te","st ","se","ntence","."]

然后我们单独处理每一个子串,依次查找与子匹配的 entity 并覆盖其格式即可。

currentPos = 0
for (let i=0; i<segments.length(); i++)  {
   for (e of entities) {
       if (e.offset>currentPos) continue
       if (e.offset+e.length<=currentPos) continue
       segments[i] = e.applyFormat(segments[i])
   }
}