# 初探富文本之在线文档交付
当我们实现在线文档的系统时,通常需要考虑到文档的导出能力,特别是对于私有化部署的复杂`ToB`产品来说,文档的私有化版本交付能力就显得非常重要。此外成熟的在线文档系统还有很多复杂的场景,都需要我们提供文档导出的能力。那么本文就以`Quill`富文本编辑器引擎为基础,探讨文档导出为`MarkDown`、`Word`、`PDF`插件化设计实现。
初探富文本编辑器系列相关文章
* [初探富文本编辑器#1-富文本概述](./初探富文本之富文本概述.md)
* [初探富文本编辑器#2-编辑器引擎](./初探富文本之编辑器引擎.md)
* [初探富文本编辑器#3-OT 协同算法](./初探富文本之OT协同算法.md)
* [初探富文本编辑器#4-OT 协同实例](./初探富文本之OT协同实例.md)
* [初探富文本编辑器#5-CRDT 协同算法](./初探富文本之CRDT协同算法.md)
* [初探富文本编辑器#6-CRDT 协同实例](./初探富文本之CRDT协同实例.md)
* [初探富文本编辑器#7-React 实时预览](./初探富文本之React实时预览.md)
* [初探富文本编辑器#8-文档 diff 算法](./初探富文本之文档diff算法.md)
* [初探富文本编辑器#9-在线文档交付](./初探富文本之在线文档交付.md)
* [初探富文本编辑器#10-划词评论能力](./初探富文本之划词评论能力.md)
* [初探富文本编辑器#11-文档虚拟滚动](./初探富文本之文档虚拟滚动.md)
* [初探富文本编辑器#12-搜索替换算法](./初探富文本之搜索替换算法.md)
* [初探富文本编辑器#13-序列化与反序列化](./初探富文本之序列化与反序列化.md)
## 概述
前段时间有位朋友跟我讲了个有趣的事,他们公司的某个`B`端大客户提了个需求,需要支持在他们的在线文档系统中直接支持远程连接打印机来打印文档。 理由非常充分,就是他们公司的大老板不喜欢盯着电脑屏幕看文档,而是希望能够阅读纸质版的文档,为了不失去这家大客户就必须高优支持这个能力,当然这也确实是一个完整的在线文档`SaaS`系统所需要支持的能力。
虽然我们的在线文档主要是以`SaaS`提供服务的,但是同样我们也可以作为`PaaS`平台来提供服务。实际上这样的场景也比较明确,例如我们的文档系统存储的数据结构通常都是自定义的数据结构,当用户想通过本地生成`MarkDown`模板的方式进行初始化文档内容时,我们就需要提供导入的能力。此时如果用户又想将文档转换为`MarkDown`模板,我们通常就又需要导出的能力,还有跨平台的数据迁移或者合作时,通常就需要我们通过`OpenAPI`提供各种各样数据转换的能力,而本质上还是基于我们的数据结构设计的一套转换系统。
回到数据转换能力本身,我们实际上可以以某种通用的数据结构类型为基准,在此基准上进行各种数据格式的转换。在我们的文档系统中,成本最小的通用数据结构就是`HTML`,我们可以以`HTML`为基准进行数据转换,并且有很多开源的实现可以参考。通过这种思路实现的数据转换是成本比较低的,但是效率上就没有那么高了,所以我们在这里聊的还是从我们的基准数据结构`DSL - Domain Specific Language`来进行数据转换。
`quill-delta`的数据结构是设计的非常棒的扁平化富文本描述`DSL`,所以本文就以`quill-delta`的数据结构设计来聊聊数据转换导出。并且我们在设计转换模型的时候,需要考虑到插件化的设计,因为我们不能够保证文档系统后边不会扩展块类型,所以这个设计思想是非常有必要的,我们即将要聊的每个转换器设计都有相关示例`https://github.com/WindrunnerMax/BlockKit/tree/master/examples`。
## MarkDown
在工作中我们可能会遇到类似的场景,用户希望将在线文档嵌入到产品本身的站点中,作为`API`文档或者帮助中心的文档使用。而由于成本的关系,这些帮助中心大都是基于`MarkDown`搭建的,毕竟维护一款富文本产品成本相当之高。那么作为`PaaS`产品我们就需要提供数据转换的能力。当然提供`SDK`直接渲染我们的数据结构也可以是我们的产品能力,但是在很多情况下是比较难以投入人力做文档渲染迁移的,所以直接通过数据转换是最低成本的方式。
实际上各种产品文档慢慢从`MarkDown`迁移到富文本是趋势所在,作为研发我们使用`MarkDown`来编写文档是比较比较常见的,所以最开始各个产品使用`MD`渲染器搭建是合理的,但是随着随着产品的迭代和用户的不断增加,运营团队与专业`TW`团队介入进来,特别是海内外都要维护的产品,就更需要运营与`TW`团队支持。
而此时我们可能只是完成初稿的编写,而后续的维护与更新就需要运营团队来维护,而运营团队通常不会使用`MD`来编写文档,特别是文档站如果是使用`Git`来管理的话,就更加难以接受了。所以对于类似的情况所见即所得在线文档产品就比较重要,而维护一款在线文档产品的成本是非常高的,那么大部分团队都可能会选择接入文档中台,由此上边我们提到的能力都变的非常重要了。
当然,作为在线文档的`PaaS`不光要提供数据转换到`MD`的能力,从`MD`导入的能力同样也是非常重要的。这里也有比较常见的场景,除了上边我们提到的用户可能是使用`MD`来编写文档模板并且导入到文档系统之外,还有已经上线的产品暂时并没有配置运营团队,此时就是使用`MD`来编写文档。而这些产品的文档是使用我们提供的文档`SDK`渲染器来提供的,都需要统一走我们的`PaaS`平台来更新文档内容,所以这种场景下数据转换为我们的`DSL`又比较重要了。实际上如果将我们定位为`PaaS`产品的话,就是要不断兼容各种场景与系统,更加类似于中台的概念。当然本文就不太涉及数据导入的能力,我们还是主要关注于数据正向转出的方案。
那么此时我们正式开始数据到`MD`的转换,首先我们需要想到一个问题,各种`MD`解析器对于语法的支持程度是不一样的,例如最基本的换行,有些解析器对于单个回车就会解析为段落,而有些解析器必须要有两个空格加回车或者两个回车才能正常解析为段落,所以为了兼容类似的情况,我们的插件化设计就必不可少。那么紧接着我们思考第二个问题,`MD`毕竟是轻量级的格式描述,而我们的`DSL`是复杂的格式描述,我们的块结构种类是非常多的,所以我们还需要`HTML`来辅助我们进行复杂格式的转换。
那么问题又来了,为什么我们不直接将其转换为`HTML`而是要混着`MD`格式呢,实际上这也是为了兼容性考虑,用户的`MD`可能组合了不同的插件,用`HTML`组合的话样式会有差异,复杂的样式组合起来会比较麻烦,特别是需要借助`mixin-react`类似`MDX`实现的方式,所以我们还是选择`MD`作为基准`HTML`作为辅助来实现数据转换。
前边我们已经提到了我们的块是比较复杂的,并且实际上是会存在很多嵌套结构,对应到`HTML`就类似于表格中嵌套了代码块的格式,而`quill-delta`的数据结构是扁平化的,所以我们也需要将其转换为方便处理的嵌套结构。而如果是完整的树形结构转换的复杂度就会就会比较高,所以我们采取一种折中的方案,在外部包裹一层`Map`结构,通过`key`的方式取得目标`delta`结构的数据,由此在数据获取的时候可以动态构成嵌套结构。
```js
// 用于对齐渲染时的数据表达
// 同时为了方便处理嵌套关系 将数据结构拍平
class DeltaSet {
private deltas: Record = {};
get(zoneId: string) {
return this.deltas[zoneId] || null;
}
push(id: string, line: Line) {
if (!this.deltas[id]) this.deltas[id] = [];
this.deltas[id].push(line);
}
}
```
同时,我们需要选取处理数据的基准,而我们的文档实际上就是由段落格式与行内格式组成,那么很明显我们就可以将其拆分为两部分,行格式与行内格式。映射到`delta`中就相当于`Line`嵌套了`Ops`并且携带了本身的行格式例如标题、对齐等,实际上加上我们的`DeltaSet`结构就是分为了三部分来描述我们初步处理希望转换到的数据结构。
```js
const ROOT_ZONE = "ROOT";
const CODE_BLOCK_KEY = "code-block";
type Line = {
attrs: Record;
ops: Op[];
};
const opsToDeltaSet = (ops: Op[]) => {
// 构造`Delta`实例
const delta = new Delta(ops);
// 将`Delta`转换为`Line`的数据表达
const group: Line[] = [];
delta.eachLine((line, attributes) => {
group.push({ attrs: attributes || {}, ops: line.ops });
});
// ...
}
```
对于`DeltaSet`我们需要定义入口`Zone`,在这里也就是`"ROOT"`标记的`delta`结构,而在`DEMO`中我们只定义了`CodeBlock`的块级嵌套结构,所以在下面的示例中我们只处理了代码块的数据嵌套表达,因为原本的数据结构是扁平的,我们就需要处理一些边界条件,也就是代码块结构的起始与结束。
当遇到代码块结构时,将正在处理的`Zone`指向为新的`delta`块,并且需要在原本的结构中建立一个指向关系,在这里是通过`op`中指定`zoneId`标识符来实现的,在结束的时候将指针恢复到之前的`Zone`目标。当然通常我们还需要处理多层嵌套的块,这里只是简单的处理了一层嵌套,多层嵌套的情况下就需要用借助栈来处理,这里就不再展开了。
```js
const deltaSet = new DeltaSet();
// 标记当前正在处理的的`ZoneId`
// 实际情况下可能会存在多层嵌套 此时需要用`stack`来处理
let currentZone: string = ROOT_ZONE;
// 标记当前处理的类型 如果存在多种类型时会用得到
let currentMode: "NORMAL" | "CODEBLOCK" = "NORMAL";
// 用于判断当前`Line`是否为`CodeBlock`
const isCodeBlockLine = (line: Line) => line && !!line.attrs[CODE_BLOCK_KEY];
// 遍历`Line`的数据表达 构造`DeltaSet`
for (let i = 0; i < group.length; ++i) {
const prev = group[i - 1];
const current = group[i];
const next = group[i + 1];
// 代码块结构的起始
if (!isCodeBlockLine(prev) && isCodeBlockLine(current)) {
const newZoneId = getUniqueId();
// 存在嵌套关系 构造新的索引
const codeBlockLine: Line = {
attrs: {},
ops: [{ insert: " ", attributes: { [CODE_BLOCK_KEY]: "true", zoneId: newZoneId } }],
};
// 需要在当前`Zone`加入指向新`Zone`的索引`Line`
deltaSet.push(currentZone, codeBlockLine);
currentZone = newZoneId;
currentMode = "CODEBLOCK";
}
// 将`Line`置入当前要处理的`Zone`
deltaSet.push(currentZone, group[i]);
// 代码块结构的结束
if (currentMode === "CODEBLOCK" && isCodeBlockLine(current) && !isCodeBlockLine(next)) {
currentZone = ROOT_ZONE;
currentMode = "NORMAL";
}
}
```
现在数据已经准备好了,我们就需要设计整个转换系统了,前边我们已经提到了整个转换器是由两种类型组成的,所以我们的插件系统也就分为了两部分,而实际上对于`MD`来说,本质上就是字符串拼接,所以对于插件的输出主要就是字符串了。此时需要注意一个问题,同一个`Op`描述可能会有多个格式,例如某个块可能是加粗与斜体的组合,此时我们的格式是由两个插件分别处理的。
那么这样的话就不能在插件中直接输出结果,而是需要通过`prefix`与`suffix`的方式拼接,同样的对于行格式也是如此,特别是需要`HTML`标签来辅助表达的情况下。此外,有时候我们可能会明确节点不会存在嵌套的情况,例如图片的格式,那么此时就可以通过`last`标识符来标记最后一个节点,由此避免多余的检查。
```js
type Output = {
prefix?: string;
suffix?: string;
last?: boolean;
};
```
由于存在需要`HTML`辅助的节点,而我们迭代的方式非常类似于递归拼接字符串的方式,所以我们需要穿插一个标识符,标识此时需要解析成`HTML`而不是`MD`标记。例如此时我们匹配到行节点是居中的,那么此时该行内部所有的节点都需要解析成`HTML`标记,而且要注意的是这个标记在每次行迭代开始前都需要重置,避免前边的内容对后边的内容造成影响。
```js
type Tag = {
isHTML?: boolean;
isInZone?: boolean;
};
```
对于插件的类型的输入部分主要是在迭代的时候将相邻的描述一并传递,这对于处理列表的格式非常有用,很多`MD`解析器是需要列表的前后都需要额外空行的,对于行内格式的合并也是非常有用的,可以避免描述块产生多个标记。此外,我们需要对插件设置唯一的标识,前边提到了我们是需要对多种场景进行兼容的,在实际处理插件的时候就可以按照实例化的顺序覆盖处理,设置插件的优先级也是很有必要的,例如引用与列表叠加的行格式,引用格式需要在列表前解析才能正确展示样式。
```js
type LineOptions = {
prev: Line | null;
current: Line;
next: Line | null;
tag: Tag;
};
type LinePlugin = {
key: string; // 插件重载
priority?: number; // 插件优先级
match: (line: Line) => boolean; // 匹配`Line`规则
processor: (options: LineOptions) => Promise | null>; // 处理函数
};
type LeafOptions = {
prev: Op | null;
current: Op;
next: Op | null;
tag: Tag;
};
type LeafPlugin = {
key: string; // 插件重载
priority?: number; // 插件优先级
match: (op: Op) => boolean; // 匹配`Op`规则
processor: (options: LeafOptions) => Promise