深入在线文档系统的 MarkDown/Word/PDF 导出能力设计

深入在线文档系统的 MarkDown/Word/PDF 导出能力设计

当我们实现在线文档的系统时,通常需要考虑到文档的导出能力,特别是对于私有化部署的复杂ToB产品来说,文档的私有化版本交付能力就显得非常重要,此外成熟的在线文档系统还有很多复杂的场景,都需要我们提供文档导出的能力。那么本文就以Quill富文本编辑器引擎为基础,探讨文档导出为MarkDownWordPDF插件化设计实现。

文章中我们即将要聊到的每个转换器设计都有相关示例https://github.com/WindrunnerMax/QuillBlocks/tree/master/examples,在实现DEMO的过程中也是踩了很多坑,给予的示例可以完成纯前端的数据转换,也可以通过Node来实现,还是比较有参考价值的。

  • delta-set.ts: 数据转换格式转换,从扁平数据结构转换到嵌套结构。
  • delta-to-md.ts: 将文档数据结构转换为Markdown,输出为纯文本结构。
  • delta-to-word.ts: 将文档数据结构转换为docx文件,输出直接写入当前目录。
  • delta-to-word.html: 文档数据转换docx文件的HTML版本,可直接在浏览器编写文档并下载word文件。
  • delta-to-pdf.ts: 将文档数据结构转换为PDF文件,输出直接写入当前目录。
  • delta-to-pdf.html: 文档数据转换PDF文件的HTML版本,可直接在浏览器编写文档并下载PDF文件。
  • pdf-with-outline.ts: 将存量PDF文件写入大纲Outline,作为输出PDF能力的补充,输出直接写入当前目录。

周末两天时间键盘都搓出火星子了才写完这篇文章,我觉得还是很有必要一键三连一下的,手动狗头。

image.png

描述

前段时间有位朋友跟我讲了个有趣的事,他们公司的某个B端大客户提了个需求,需要支持在他们的在线文档系统中直接支持远程连接打印机来打印文档,理由非常充分,就是他们公司的大老板不喜欢盯着电脑屏幕看文档,而是希望能够阅读纸质版的文档,为了不失去这家大客户就必须高优支持这个能力,当然这也确实是一个完整的在线文档SaaS系统所需要支持的能力。

虽然我们的在线文档主要是以SaaS提供服务的,但是同样我们也可以作为PaaS平台来提供服务,实际上这样的场景也比较明确,例如我们的文档系统存储的数据结构通常都是自定义的数据结构,当用户想通过本地生成MarkDown模版的方式进行初始化文档内容时,我们就需要提供导入的能力,此时如果用户又想将文档转换为MarkDown模版,我们通常就又需要导出的能力,还有跨平台的数据迁移或者合作时,通常就需要我们通过OpenAPI提供各种各样数据转换的能力,而本质上还是基于我们的数据结构设计的一套转换系统。

回到数据转换能力本身,我们实际上可以以某种通用的数据结构类型为基准,在此基准上进行各种数据格式的转换,在我们的文档系统中,成本最小的通用数据结构就是HTML,我们可以以HTML为基准进行数据转换,并且有很多开源的实现可以参考。通过这种思路实现的数据转换是成本比较低的,但是效率上就没有那么高了,所以我们在这里聊的还是从我们的基准数据结构DSL - Domain Specific Language来进行数据转换,quill-delta的数据结构是设计的非常棒的扁平化富文本描述DSL,所以本文就以quill-delta的数据结构设计来聊聊数据转换导出。并且我们在设计转换模型的时候,需要考虑到插件化的设计,因为我们不能够保证文档系统后边不会扩展块类型,所以这个设计思想是非常有必要的。

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结构的数据,由此在数据获取的时候可以动态构成嵌套结构。

// 用于对齐渲染时的数据表达
// 同时为了方便处理嵌套关系 将数据结构拍平
class DeltaSet {
   
   
  private deltas: Record<string, Line[]> = {
   
   };

  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结构就是分为了三部分来描述我们初步处理希望转换到的数据结构。

const ROOT_ZONE = "ROOT";
const CODE_BLOCK_KEY = "code-block";
type Line = {
   
   
  attrs: Record<string, boolean | string | number>;
  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目标。当然通常我们还需要处理多层嵌套的块,这里只是简单的处理了一层嵌套,多层嵌套的情况下就需要用借助栈来处理,这里就不再展开了。

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描述可能会有多个格式,例如某个块可能是加粗与斜体的组合,此时我们的格式是由两个插件分别处理的,那么这样的话就不能在插件中直接输出结果,而是需要通过prefixsuffix的方式拼接,同样的对于行格式也是如此,特别是需要HTML标签来辅助表达的情况下。此外,有时候我们可能会明确节点不会存在嵌套的情况,例如图片的格式,那么此时就可以通过last标识符来标记最后一个节点,由此避免多余的检查。

type Output = {
   
   
  prefix?: string;
  suffix?: string;
  last?: boolean;
};

由于存在需要HTML辅助的节点,而我们迭代的方式非常类似于递归拼接字符串的方式,所以我们需要穿插一个标识符,标识此时需要解析成HTML而不是MD标记,例如此时我们匹配到行节点是居中的,那么此时该行内部所有的节点都需要解析成HTML标记,而且要注意的是这个标记在每次行迭代开始前都需要重置,避免前边的内容对后边的内容造成影响。

type Tag = {
   
   
  isHTML?: boolean;
  isInZone?: boolean;
};

对于插件的类型的输入部分主要是在迭代的时候将相邻的描述一并传递,这对于处理列表的格式非常有用,很多MD解析器是需要列表的前后都需要额外空行的,对于行内格式的合并也是非常有用的,可以避免描述块产生多个标记。此外,我们需要对插件设置唯一的标识,前边提到了我们是需要对多种场景进行兼容的,在实际处理插件的时候就可以按照实例化的顺序覆盖处理,设置插件的优先级也是很有必要的,例如引用与列表叠加的行格式,引用格式需要在列表前解析才能正确展示样式。

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<Omit<Output, "last"> | 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<Output | null>; // 处理函数
};

接下来是入口的处理函数,首先我们需要处理行格式,因为行内格式可能会因为行格式出现不同的结果,例如居中的行格式会导致行内格式解析成HTML标记,这个标记是通过可变的tag对象来实现的,我们的行格式是有可能会匹配到多个插件的,所有的结果都应该保存起来,同样的对于行内格式也是如此,在处理函数的最后,我们将结果拼接为字符串即可。

const parseZoneContent = async (
  zoneId: string,
  options: {
   
    defaultZoneTag?: Tag; wrap?: string }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值