初探富文本之在线文档交付

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

描述

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

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

回到数据转换能力本身,我们实际上可以以某种通用的数据结构类型为基准,在此基准上进行各种数据格式的转换。在我们的文档系统中,成本最小的通用数据结构就是HTML,我们可以以HTML为基准进行数据转换,并且有很多开源的实现可以参考。通过这种思路实现的数据转换是成本比较低的,但是效率上就没有那么高了,所以我们在这里聊的还是从我们的基准数据结构DSL - Domain Specific Language来进行数据转换。quill-delta的数据结构是设计的非常棒的扁平化富文本描述DSL,所以本文就以quill-delta的数据结构设计来聊聊数据转换导出。并且我们在设计转换模型的时候,需要考虑到插件化的设计,因为我们不能够保证文档系统后边不会扩展块类型,所以这个设计思想是非常有必要的,我们即将要聊的每个转换器设计都有相关示例https://github.com/WindrunnerMax/QuillBlocks/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结构的数据,由此在数据获取的时候可以动态构成嵌套结构。

// 用于对齐渲染时的数据表达
// 同时为了方便处理嵌套关系 将数据结构拍平
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 }
): Promise<string | null> => {
  const { defaultZoneTag = {}, wrap: cut = "\n\n" } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const result: string[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行数据
    const prefixLineGroup: string[] = [];
    const suffixLineGroup: string[] = [];
    // 不能影响外部传递的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 先处理行内容 // 需要先处理行格式
    for (const linePlugin of LINE_PLUGINS) {
      if (!linePlugin.match(currentLine)) continue;
      // ... 执行插件
      if (!result) continue;
      result.prefix && prefixLineGroup.push(result.prefix);
      result.suffix && suffixLineGroup.push(result.suffix);
    }
    const ops = currentLine.ops;
    // 处理节点内容
    for (let k = 0; k < ops.length; ++k) {
      // ... 取节点数据
      const prefixOpGroup: string[] = [];
      const suffixOpGroup: string[] = [];
      let last = false;
      for (const leafPlugin of LEAF_PLUGINS) {
        if (!leafPlugin.match(currentOp)) continue;
        // ... 执行插件
        if (!result) continue;
        result.prefix && prefixOpGroup.push(result.prefix);
        result.suffix && suffixOpGroup.unshift(result.suffix);
        if (result.last) {
          last = true;
          break;
        }
      }
      // 如果没有匹配到`last`则需要默认加入节点内容
      if (!last && currentOp.insert && isString(currentOp.insert)) {
        prefixOpGroup.push(currentOp.insert);
      }
      prefixLineGroup.push(prefixOpGroup.join("") + suffixOpGroup.join(""));
    }
    result.push(prefixLineGroup.join("") + suffixLineGroup.join(""));
  }
  return result.join(cut);
};

那么有了调度器,我们接下来只需要关注插件的实现,在这里以标题插件为例实现转换逻辑,实际上这部分逻辑非常简单,只需要解析LineAttributes来决定返回值就可以了。

const HeadingPlugin: LinePlugin = {
  key: "HEADING",
  match: line => !!line.attrs.header,
  processor: async options => {
    if (options.tag.isHTML) {
      options.tag.isHTML = true;
      return {
        prefix: `<h${options.current.attrs.header}>`,
        suffix: `</h${options.current.attrs.header}>`,
      };
    } else {
      const repeat = Number(options.current.attrs.header);
      return { prefix: "#".repeat(repeat) + " " };
    }
  },
};

对于行内的插件也是类似的逻辑,在这里以加粗插件为例实现转逻辑,同样也是仅需要判断OpAttributes来决定返回值即可。

const BoldPlugin: LeafPlugin = {
  key: "BOLD",
  match: op => op.attributes && op.attributes.bold,
  processor: async options => {
    if (options.tag.isHTML) {
      options.tag.isHTML = true;
      return { prefix: "<strong>", suffix: "</strong>" };
    } else {
      return { prefix: "**", suffix: "**" };
    }
  },
};

https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/中有完整的DeltaSet数据转换delta-set.tsMarkDown数据转换delta-to-md.ts,可以通过ts-node来执行测试。实际上我们可能也注意到了,这个调度器不仅可以转换MD格式,实际上还可以进行完整的HTML格式转换,那么既然HTML转换逻辑有了,我们就有了非常通用的中间产物来生成各种文件了,并且如果将插件改装成同步的模式,这个方案还可以用来处理在线文档的复制行为,实际的用途就非常丰富了。此外,在实际使用的过程中对于插件的单测是非常有必要的,在开发的时候就应该就测试用例全部积累起来,用以避免改动所造成的未知问题,特别是当多个插件组合的时候,兼容的业务场景一旦复杂起来,对于各种case的处理就会变的尤为重要,特别是全量同步更新的场景下,积累边界的测试用例就变得更加重要。

Word

在前边我们聊了作为PaaS平台的数据转换兼容能力,而作为SaaS平台直接生成交付文档是必不可少的能力,特别是在产品需要私有化部署以及提供多版本线上能力的时候。Word是最常见的文档交付格式之一,特别是在需要导出后再次修改的情况下生成Word文档就变得非常有用,所以在本节我们就来聊一下如何生成Word格式的交付文档。

OOXMLOffice Open XML是微软在Office 2007中提出的一种新的文档格式,Office 2007中的WordExcelPowerPoint默认均采用OOXML格式,OOXML同样也成为了ECMA规范的一部分,编号为ECMA-376。实际上对于现在的Word文档,我们可以直接将其解压从而得到封装的数据,将其扩展名修改为zip之后,就可以得到内部的文件,下面是docx文件中的部分组成。

  • [Content_Types].xml: 用于定义里面每个文件的内容类型,例如可以标记一个文件是图片.jpg还是文本内容.xml
  • _rels: 通常会存在.rels文件,用以保存各个Part之间的关系,用来描述不同文件之间的关联,例如某文本与图片存在关联。
  • docProps: 其中存放了整个word文档的属性信息,如作者、创建时间、标签等。
  • word: 存储的是文档的主要内容,包括文本、图片、表格以及样式等。
    • document.xml: 保存了所有的文本以及对文本的引用。
    • styles.xml: 保存了文档中所有使用到的样式。
    • theme.xml: 保存了应用于文档的主题设置。
    • media: 保存了文档中使用的所有媒体文件,如图片。

看到这些描述我们可能会非常迷茫应该如何真正组装成word文件,毕竟这里有如此多复杂的关系描述。那么既然我们不能瞬间了解整个docx文件的构成,我们还是可以借助于框架来生成docx文件的,在调研了一些框架后,我发现大概有两种生成方式,一种就是我们常说的通过通用的HTML格式来生成,例如html-docx-jshtml-to-docxpandoc,还有一种是代码直接控制生成,相当于减少了转HTML这一步,例如officegendocx。在观察到很多库实际上很多年没有过更新了,并且在这里我们更希望直接输出docx,而不是需要HTML中转,毕竟在线文档的交付对于格式还是需要有比较高的控制能力的,综上最后选择使用docx来生成word文件。

docx帮我们简化了整个word文件的生成过程,通过构建内建对象的层级关系,我们就可以很方便的生成出最后的文件,并且无论是在Node环境还是浏览器环境中都可以运行,所以在本节的DEMO中会有Node和浏览器两个版本的DEMO。那么现在我们就以Node版本为例聊聊如何生成word文件,首先我们需要定义样式,在word中有一个称作样式窗格的模块,我们可以将其理解为CSSclass,这样我们就可以在生成文档的时候直接引用样式,而不需要在每个节点中都定义一遍样式。

const PAGE_SIZE = {
  WIDTH: sectionPageSizeDefaults.WIDTH - 1440 * 2,
  HEIGHT: sectionPageSizeDefaults.HEIGHT - 1440 * 2,
};
const DEFAULT_FORMAT_TYPE = {
  H1: "H1",
  H2: "H2",
  CONTENT: "Content",
  IMAGE: "Image",
  HF: "HF",
};
// ... 基本配置
const PRESET_SCHEME_LIST: IParagraphStyleOptions[] = [
  {
    id: DEFAULT_FORMAT_TYPE.CONTENT,
    name: DEFAULT_FORMAT_TYPE.CONTENT,
    quickFormat: true,
    paragraph: {
      spacing: DEFAULT_LINE_SPACING_FORMAT,
    },
  },
  // ... 预设格式
]

紧接着我们需要处理单位的转换,在我们使用word的时候可能会注意到我们的单位都是磅值PT,而在我们的浏览器中通常是PX,因为在DEMO中我们仅涉及到了图片大小的处理,其他的都是直接使用DAX与比例的方式实现的,所以在这里只是列举了用到的单位转换。

const daxToCM = (dax: number) => (dax / 20 / 72) * 2.54;
const cmToPixel = (cm: number) => cm * 10 * 3.7795275591;
const daxToPixel = (dax: number) => Math.ceil(cmToPixel(daxToCM(dax)));

与转换MD类似,我们同样需要定义转换调度的逻辑,但是有一点不一样的是MD中输出是字符串,我们的可操作性很大,在docx中是有严格的对象结构关系的,所以在这里我们需要严格定义行与行内的类型关系,并且传递的Tag需要有更多的内容。

type LineBlock = Table | Paragraph;
type LeafBlock = Run | Table | ExternalHyperlink;
type Tag = {
  width: number;
  fontSize?: number;
  fontColor?: string;
  spacing?: ISpacingProperties;
  paragraphFormat?: string;
  isInZone?: boolean;
  isInCodeBlock?: boolean;
};

插件的输入设计与MD类似,但是输出的内容就需要更加严格,行内元素的插件输出必须是行内的对象类型,行元素的插件输出必须要是行对象类型,特别要注意的是在行插件中,我们传递了leaves参数,这里也就意味着此时我们的行内元素与行元素的调度是由行插件来管理,而不是在外部Zone调度模块来管理。

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<LeafBlock | null>; // 处理函数
};
type LineOptions = {
  prev: Line | null;
  current: Line;
  next: Line | null;
  tag: Tag;
  leaves: LeafBlock[];
};
type LinePlugin = {
  key: string; // 插件重载
  priority?: number; // 插件优先级
  match: (line: Line) => boolean; // 匹配`Line`规则
  processor: (options: LineOptions) => Promise<LineBlock | null>; // 处理函数
};

接下来就是入口的Zone调度函数,这里与之前的MD调度不同,我们需要首先处理叶子节点也就是行内样式,因为这里有一个特别需要关注的点是Paragraph对象是不能包裹Table对象的,而此时如果我们需要实现一个块级结构那么外部是需要包裹Table而不是Paragraph,也就是说此时我们的行内元素内容是会决定行元素的格式,即A影响B那就先处理A,所以此时是先处理行内元素,并且单个块结构仅会匹配到一个插件,所以相关的通用内容处理是需要封装到通用函数中的。

const parseZoneContent = async (
  zoneId: string,
  options: { defaultZoneTag?: Tag }
): Promise<LineBlock[] | null> => {
  const { defaultZoneTag = { width: PAGE_SIZE.WIDTH } } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const target: LineBlock[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行数据
    // 不能影响外部传递的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 处理节点内容
    const ops = currentLine.ops;
    const leaves: LeafBlock[] = [];
    for (let k = 0; k < ops.length; ++k) {
      // ... 取节点数据
      const hit = LEAF_PLUGINS.find(leafPlugin => leafPlugin.match(currentOp));
      if (hit) {
        // ... 执行插件
        result && leaves.push(result);
      }
    }
    // 处理行内容
    const hit = LINE_PLUGINS.find(linePlugin => linePlugin.match(currentLine));
    if (hit) {
      // ... 执行插件
      result && target.push(result);
    }
  }
  return target;
};

接下来同样的我们需要定义插件,这里以文本插件为例实现转换逻辑,因为基本的文本样式都封装在TextRun这个对象中,所以我们只需要处理TextRun对象的属性即可,当然对于其他的Run类型对象例如ImageRun等,我们还是需要单独定义插件处理的。

const TextPlugin: LeafPlugin = {
  key: "TEXT",
  match: () => true,
  processor: async (options: LeafOptions) => {
    const { current, tag } = options;
    if (!isString(current.insert)) return null;
    const config: WithDefaultOption<IRunOptions> = {};
    config.text = current.insert;
    const attrs = current.attributes || {};
    if (attrs.bold) config.bold = true;
    if (attrs.italic) config.italics = true;
    if (attrs.underline) config.underline = {};
    if (tag.fontSize) config.size = tag.fontSize;
    if (tag.fontColor) config.color = tag.fontColor;
    return new TextRun(config);
  },
};

对于行类型的插件,我们以段落插件为例实现转换逻辑,对于段落插件是当匹配不到其他段落格式时需要最终并入的插件。前边我们提到的Paragraph对象是不能包裹Table元素的问题也需要在此处处理,因为我们的块级表达就是借助Table对象实现的,那么如果叶子节点没有匹配到块元素,则直接返回段落元素即可,如果匹配到了块元素且仅有单个元素,那么将其直接提升并返回即可,如果匹配到块元素且还有其他元素,那么此时就需要将所有的元素包裹一层块元素再返回,实际上这部分逻辑应该封装起来为所有的行级元素插件共同调用来兼容解析,否则层级嵌套出现问题的话生成的word是无法打开的。

const ParagraphPlugin: LinePlugin = {
  key: "PARAGRAPH",
  match: () => true,
  processor: async (options: LineOptions) => {
    const { leaves, tag } = options;
    const config: WithDefaultOption<IParagraphOptions> = {};
    const isBlockNode = leaves.some(leaf => leaf instanceof Table);
    config.style = tag.paragraphFormat || DEFAULT_FORMAT_TYPE.CONTENT;
    if (!isBlockNode) {
      if (tag.spacing) config.spacing = tag.spacing;
      config.children = leaves;
      return new Paragraph(config);
    } else {
      if (leaves.length === 1 && leaves[0] instanceof Table) {
        // 单个`Zone`不需要包裹 通常是独立的块元素
        return leaves[0] as Table;
      } else {
        // 需要包裹组合嵌套`BlockTable`
        return makeZoneBlock({ children: leaves });
      }
    }
  },
};

接下来我们再来聊一下页眉和页脚,在word中我们常见的一个页眉表达是在右上角标识当前页的标题,这是个很有意思的功能,在word中是通过域来实现的,借助于OOXML的表达和docx的封装,我们同样也可以实现这个功能,而且对于类似域表达的实现同样都是可以实现的,引用标题常用的域表达是STYLEREF,我们直接拼装字符串即可,常见的一个页脚表达是在右下角或者居中显示页码的功能,这部分就不需要域引用的表达了,我们可以非常简单地实现页码的展示,主要关注的点还是位置的控制。

const HeaderSection = new Header({
  children: [
    new Paragraph({
      style: DEFAULT_FORMAT_TYPE.HF,
      tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
      // ... 格式控制
      children: [
        new TextRun("页眉"),
        new TextRun({
          children: [
            new Tab(),
            new SimpleField(`STYLEREF "${DEFAULT_FORMAT_TYPE.H1}" \\* MERGEFORMAT`),
          ],
        }),
      ],
    }),
  ],
});

const FooterSection = new Footer({
  children: [
    new Paragraph({
      style: DEFAULT_FORMAT_TYPE.HF,
      tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
      // ... 格式控制
      children: [
        new TextRun("页脚"),
        new TextRun({
          children: [new Tab(), PageNumber.CURRENT],
        }),
      ],
    }),
  ],
});

word中还有一个非常重要的功能,那就是生成目录的能力,我们先来想一个问题,不知道大家注意到没有我们整篇文档没有提到字体的引入,如果我们想知道某个字或者某个段落渲染在word中的某一页,那么我们是需要知道字体的大小的,这样我们才可以将其排版,由此得到标题所在的页数,那么既然我们连字体都没引入,那么实际上很明显我们是没有在生成文档的时候就进行渲染排版的执行,而是在用户打开文档的时候才会进行这个操作,所以我们引入目录之后,会出现类似于是否更新该文档中的这些域的提示,这就是因为目录是字段,根据设计其内容仅由word生成或更新,我们无法以编程方式做到这一点。

const TOC = new TableOfContents("Table Of Contents", {
  hyperlink: true,
  headingStyleRange: "1-2",
  stylesWithLevels: [
    new StyleLevel(DEFAULT_FORMAT_TYPE.H1, 1),
    new StyleLevel(DEFAULT_FORMAT_TYPE.H2, 2),
  ],
}),

https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/中有完整的word数据转换delta-to-word.tsdelta-to-word.html,可以通过ts-node和浏览器打开HTML来执行测试。从数据层面转换生成word实际上是件非常复杂的问题,并且其中还有很多细节需要处理,特别是在富文本内容的转换上,例如多层级块嵌套、流程图/图片渲染、表格合并、动态内容转换等等,实现完备的word导出能力同样也需要不断适配各种边界case,同样非常需要单元测试来辅助我们保持功能的稳定性。

PDF

在我们的SaaS平台上的交付能力,除了Word之外PDF也是必不可少的,实际上对于很多需要打印的文档来说,PDF是更好的选择。因为PDF是一种固定格式的文档,不会因为不同的设备而产生排版问题,我们也可以将PDF理解为高级的图片,图片不会因为设备不同而导致排版混乱,高级则是高级在其可添加的内容更加丰富,所以在本节我们就来聊一下如何生成PDF格式的交付文档。

生成PDF的方法同样可以归为两种,一种是基于HTML生成PDF,常见的做法是通过dom-to-image/html2canvas等库将HTML转换为图片,再将图片转换为HTML,这种方式缺点比较明显,不能对文字进行选择复制,放大后清晰度会下降。还有一种常见的方式是使用Puppeteer,其提供了高级API来通过DevTools协议控制Chromium,可以用来生成PDF文件,同样的如果在前端直接使用window.print或者react-to-print借助iframe实现局部打印也是可行的;还有一种方式是自行排版生成PDF,对于PDF的操作实际上非常类似于Canvas的操作,任何东西都可以通过绘制的方式来实现,例如表格我们就可以直接通过画矩形的方式来绘制,常用的库有pdfkitpdf-libpdfmake等等。

同样的在这里我们讨论的方法是从我们的delta数据直接生成PDF,当然因为我们前边也聊了生成MDHTMLWord格式的文件,通过这些文件作为中间层的数据进行转换也是完全可行的,只不过在这里我们还是采用直接输出的方式。同样我们也不太能在短时间内完整熟悉整个PDF数据格式的标准,所以我们同样还是借助于库来生成PDF文件,这里我们选择了pafmake来生成PDF,通过pdfmake我们可以通过JSON配置的方式自动排版和生成PDF,相当于是从一种JSON生成了另一种JSON,而针对于Outline/Bookmark的问题,我花了很长时间研究相关实现,最终选择了pdf-lib来最终处理生成大纲。

与生成Word的描述语言OOXML不同,OOXML中不包含任何用于直接渲染内容的绘图指令,实际上还是相当于静态标记,当用户打开docx文件时会解析标记在用户客户端进行渲染。而创建PDF时需要真正绘制路径PostScript-PDL,是直接描绘文本、矢量图形和图像的页面描述语言,而不是需要由客户端渲染排版的格式,当PDF文件被打开时,所有的绘图指令都已经在PDF文件中,内容可以直接通过这些绘图指令渲染出来。

为了保持保持完整的跨平台文档格式,PDF文件中通常还需要嵌入字体,这样才能保证在任何设备上都能正确显示文档内容,所以在生成PDF文件时我们需要引入字体文件。需要注意的是,很多字体都不是免费使用的,特别是在公司中很多都是需要商业授权的,同样也有很多开源的字体,可以考虑思源宋体与江城斜宋体,这样就包含了normalbolditalicsbolditalics四种格式的字体了,在服务端也可以考虑直接安装fonts-noto-cjk字体并引用。此外通常CJK的字体文件都会比较大,子集化字体嵌入是更好的选择。

// 需要引用字体 可以考虑思源宋体 + 江城斜宋体
// https://github.com/RollDevil/SourceHanSerifSC
const FONT_PATH = "/Users/czy/Library/Fonts/";
const FONTS = {
  JetBrainsMono: {
    normal: FONT_PATH + "JetBrainsMono-Regular.ttf",
    bold: FONT_PATH + "JetBrainsMono-Bold.ttf",
    italics: FONT_PATH + "JetBrainsMono-Italic.ttf",
    bolditalics: FONT_PATH + "JetBrainsMono-BoldItalic.ttf",
  },
};

pdfmake中我们同样可以通过预设样式来实现类似word的样式窗格功能,当然pdf是不能直接编辑的,所以此处的样式窗格主要是方便我们实现不同类型的样式。

const FORMAT_TYPE = {
  H1: "H1",
  H2: "H2",
};
const PRESET_FORMAT: StyleDictionary = {
  [FORMAT_TYPE.H1]: { fontSize: 22, bold: true, },
  [FORMAT_TYPE.H2]: { fontSize: 18, bold: true, },
};
const DEFAULT_FORMAT: Style = {
  font: "JetBrainsMono",
  fontSize: 14,
};

对于转换调度模块,与word的调度模块类似,我们需要定义行与行内的类型关系以及Tag需要传递的内容。关于pdfmake的类型控制是非常松散的,我们可以轻松地实现符合要求的格式嵌套,当然不合法的格式嵌套还是运行时校验的,我们可以做的是尽可能地将这部分校验提升到类型定义时,例如ContentText实际上是不能直接以ContentImage作为子元素的,但是在类型定义上是允许的,我们可以更加严格地定义类似的嵌套关系。

type LineBlock = Content;
type LeafBlock = ContentText | ContentTable | ContentImage;
type Tag = {
  format?: string;
  fontSize?: number;
  isInZone?: boolean;
  isInCodeBlock?: boolean;
};

关于插件定义的部分我们还是延续之前设计的类型,这部分大致都是相同的设计,入参依然是相邻的块结构以及Tag,行插件还并入了叶子节点数据,插件的定义上依旧保持key插件重载、priority插件优先级、match匹配规则、processor处理函数,输出依旧是两种块类型,实际上这也从侧面反映了我们之前的设计还是比较通用的。

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<LeafBlock | null>; // 处理函数
};
type LineOptions = {
  prev: Line | null;
  current: Line;
  next: Line | null;
  tag: Tag;
  leaves: LeafBlock[];
};
type LinePlugin = {
  key: string; // 插件重载
  priority?: number; // 插件优先级
  match: (line: Line) => boolean; // 匹配`Line`规则
  processor: (options: LineOptions) => Promise<LineBlock | null>; // 处理函数
};

入口的Zone调度函数,与处理word的部分比较类似,因为不存在单个块结构的嵌套关系,同类型所有的格式配置都可以用同一个插件来实现,所以这里同样是命中单个插件的形式,此外同样是首先处理叶子节点,因为叶子节点的内容会决定行元素的嵌套块格式。

const parseZoneContent = async (
  zoneId: string,
  options: { defaultZoneTag?: Tag }
): Promise<Content[] | null> => {
  const { defaultZoneTag = {} } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const target: Content[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行数据
    // 不能影响外部传递的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 处理节点内容
    const ops = currentLine.ops;
    const leaves: LeafBlock[] = [];
    for (let k = 0; k < ops.length; ++k) {
      // ... 取节点数据
      const hit = LEAF_PLUGINS.find(leafPlugin => leafPlugin.match(currentOp));
      if (hit) {
        // ... 执行插件
        result && leaves.push(result);
      }
    }
    // 处理行内容
    const hit = LINE_PLUGINS.find(linePlugin => linePlugin.match(currentLine));
    if (hit) {
      // ... 执行插件
      result && target.push(result);
    }
  }
  return target;
};

紧接着是插件的定义,这里以文本插件为例实现转换逻辑,类似的基本文本样式都封装在ContentText这个对象中,所以我们只需要处理ContentText对象的属性即可,当然对于其他的Content类型对象例如ContentImage等,我们还是需要单独定义插件处理的。

const TextPlugin: LeafPlugin = {
  key: "TEXT",
  match: () => true,
  processor: async (options: LeafOptions) => {
    const { current, tag } = options;
    if (!isString(current.insert)) return null;
    const config: ContentText = {
      text: current.insert,
    };
    const attrs = current.attributes || {};
    if (attrs.bold) config.bold = true;
    if (attrs.italic) config.italics = true;
    if (attrs.underline) config.decoration = "underline";
    if (tag.fontSize) config.fontSize = tag.fontSize;
    return config;
  },
};

对于行类型的插件,我们以段落插件为例实现转换逻辑,对于段落插件是当匹配不到其他段落格式时需要最终并入的插件,前边我们提到的Content对象的嵌套关系也需要在此处处理,首先对于空行需要并入一个\n,如果是空对象或者空数组的话是不会出现换行行为的,对于单个的Zone内容就不需要包裹,例如CodeBlock块级结构则直接提升并入到主文档即可,对于多种多种类型的结构例如并行的表格、图片等就需要包裹一层Table/Columns结构来实现。此外与OOXML不一样的是,层级嵌套关系出现问题不会导致打开报错,只是不正常显示相关区域的内容。

const composeParagraph = (leaves: LeafBlock[]): LeafBlock => {
  if (leaves.length === 0) {
    // 空行需要兜底
    return { text: "\n" };
  } else if (leaves.length === 1 && !leaves[0].text) {
    // 单个`Zone`不需要包裹 通常是独立的块元素
    return leaves[0];
  } else {
    const isContainBlock = leaves.some(leaf => !leaf.text);
    if (isContainBlock) {
      // 需要包裹组合嵌套`BlockTable` // 实际还需要计算宽度避免越界
      return { layout: "noBorders", table: { headerRows: 0, body: [leaves] } };
    } else {
      return { text: leaves };
    }
  }
};
const ParagraphPlugin: LinePlugin = {
  key: "PARAGRAPH",
  match: () => true,
  processor: async (options: LineOptions) => {
    const { leaves } = options;
    return composeParagraph(leaves);
  },
};

紧接着我们来聊一聊如何生成Outline/BookmarkOutline通常就是我们说的大纲,通常会显示在打开的PDF左侧。pdfmake是不支持直接生成Outline的,所以我们需要借助其他的库来实现这个功能,在调研了很长时间之后我发现了pdf-lib这个库,可以用来处理已有的pdf文件并且生成Outline。在这个例子中生成PDF之后的Outline是通过id系统来实现跳转的,实际上还有一个思路,使用pdfjs-dist来解析并存储PDF相应标题对应的页面与位置信息,然后再使用pdf-libOutline写入。此外,生成Outline在配合Puppeteer来生成PDF时非常有用,本质上是因为Chromium在导出PDF时不支持生成Outline,那么通过pdf-lib来添加Outline恰好是不错的能力补充。

// 通过`pdfmake`生成`pdf`
const printer = new PdfPrinter(FONTS);
const pdfDoc = printer.createPdfKitDocument(doc);
const writableStream = new Stream.Writable();
const slice: Uint8Array[] = [];
writableStream._write = (chunk: Uint8Array, _, next) => {
  slice.push(chunk);
  next();
};
pdfDoc.pipe(writableStream);
const buffer = await new Promise<Buffer>(resolve => {
  writableStream.on("finish", () => {
    const data = Buffer.concat(slice);
    resolve(data);
  });
});
pdfDoc.end();

// 通过`pdf-lib`生成`outline`
const pdf = await PDFDocument.load(buffer);
const context = pdf.context;
const root = context.nextRef();
const header1 = context.nextRef();
const header11 = context.nextRef();
// ... 创建`ref`
const header1Map: DictMap = new Map([]);
// ... 置入数据
header1Map.set(PDFName.of("Dest"), PDFName.of("Hash1"));
context.assign(header1, PDFDict.fromMapWithContext(header1Map, context));
const header11Map: DictMap = new Map([]);
// ... 置入数据
header12Map.set(PDFName.of("Dest"), PDFName.of("Hash1.2"));
context.assign(header11, PDFDict.fromMapWithContext(header11Map, context));
// ... 构建完整的层级关系
const rootMap: DictMap = new Map([]);
// ... 构建根节点的引用
context.assign(root, PDFDict.fromMapWithContext(rootMap, context));
pdf.catalog.set(PDFName.of("Outlines"), root);
// 生成并写文件
const pdfBytes = await pdf.save();
fs.writeFileSync(__dirname + "/doc-with-outline.pdf", pdfBytes);

https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/中有完整的PDF数据转换delta-to-pdf.tsdelta-to-pdf.html,以及添加Outlinepdf-with-outline.ts,可以通过ts-node和浏览器打开HTML来执行测试,特别注意使用ts-node进行测试的时候需要注意字体的引用。从数据层面转换生成PDF本身是件非常复杂的问题,而得益于诸多的开源项目我们可以比较轻松地完成这件事,但是当真正地将其应用到生产环境中时,实现完备的PDF导出能力同样也需要不断适配各种边界case,同样非常需要单元测试来辅助我们保持功能的稳定性。

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://docx.js.org/ https://github.com/parallax/jsPDF https://github.com/foliojs/pdfkit https://github.com/Hopding/pdf-lib https://quilljs.com/playground/snow https://github.com/puppeteer/puppeteer https://github.com/lillallol/outline-pdf https://github.com/bpampuch/pdfmake/tree/0.2 http://officeopenxml.com/WPcontentOverview.php