当我们实现在线文档的系统时,通常需要考虑到文档的导出能力,特别是对于私有化部署的复杂ToB
产品来说,文档的私有化版本交付能力就显得非常重要,此外成熟的在线文档系统还有很多复杂的场景,都需要我们提供文档导出的能力。那么本文就以Quill
富文本编辑器引擎为基础,探讨文档导出为MarkDown
、Word
、PDF
插件化设计实现。
前段时间有位朋友跟我讲了个有趣的事,他们公司的某个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
。
在工作中我们可能会遇到类似的场景,用户希望将在线文档嵌入到产品本身的站点中,作为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
描述可能会有多个格式,例如某个块可能是加粗与斜体的组合,此时我们的格式是由两个插件分别处理的,那么这样的话就不能在插件中直接输出结果,而是需要通过prefix
与suffix
的方式拼接,同样的对于行格式也是如此,特别是需要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.ts
和MarkDown
数据转换delta-to-md.ts
,可以通过ts-node
来执行测试,实际上我们可能也注意到了,这个调度器不仅可以转换MD
格式,实际上还可以进行完整的HTML
格式转换,那么既然HTML
转换逻辑有了,我们就有了非常通用的中间产物来生成各种文件了,并且如果将插件改装成同步的模式,这个方案还可以用来处理在线文档的复制行为,实际的用途就非常丰富了。此外,在实际使用的过程中对于插件的单测是非常有必要的,在开发的时候就应该就测试用例全部积累起来,用以避免改动所造成的未知问题,特别是当多个插件组合的时候,兼容的业务场景一旦复杂起来,对于各种case
的处理就会变的尤为重要,特别是全量同步更新的场景下,积累边界的测试用例就变得更加重要。
在前边我们聊了作为PaaS
平台的数据转换兼容能力,而作为SaaS
平台直接生成交付文档是必不可少的能力,特别是在产品需要私有化部署以及提供多版本线上能力的时候。Word
是最常见的文档交付格式之一,特别是在需要导出后再次修改的情况下生成Word
文档就变得非常有用,所以在本节我们就来聊一下如何生成Word
格式的交付文档。
OOXML
即Office Open XML
是微软在Office 2007
中提出的一种新的文档格式,Office 2007
中的Word
、Excel
、PowerPoint
默认均采用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-js
、html-to-docx
、pandoc
,还有一种是代码直接控制生成,相当于减少了转HTML
这一步,例如officegen
、docx
。在观察到很多库实际上很多年没有过更新了,并且在这里我们更希望直接输出docx
,而不是需要HTML
中转,毕竟在线文档的交付对于格式还是需要有比较高的控制能力的,综上最后选择使用docx
来生成word
文件。
docx
帮我们简化了整个word
文件的生成过程,通过构建内建对象的层级关系,我们就可以很方便的生成出最后的文件,并且无论是在Node
环境还是浏览器环境中都可以运行,所以在本节的DEMO
中会有Node
和浏览器两个版本的DEMO
。那么现在我们就以Node
版本为例聊聊如何生成word
文件,首先我们需要定义样式,在word
中有一个称作样式窗格的模块,我们可以将其理解为CSS
的class
,这样我们就可以在生成文档的时候直接引用样式,而不需要在每个节点中都定义一遍样式。
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.ts
和delta-to-word.html
,可以通过ts-node
和浏览器打开HTML
来执行测试。从数据层面转换生成word
实际上是件非常复杂的问题,并且其中还有很多细节需要处理,特别是在富文本内容的转换上,例如多层级块嵌套、流程图/图片渲染、表格合并、动态内容转换等等,实现完备的word
导出能力同样也需要不断适配各种边界case
,同样非常需要单元测试来辅助我们保持功能的稳定性。
在我们的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
的操作,任何东西都可以通过绘制的方式来实现,例如表格我们就可以直接通过画矩形的方式来绘制,常用的库有pdfkit
、pdf-lib
、pdfmake
等等。
同样的在这里我们讨论的方法是从我们的delta
数据直接生成PDF
,当然因为我们前边也聊了生成MD
、HTML
、Word
格式的文件,通过这些文件作为中间层的数据进行转换也是完全可行的,只不过在这里我们还是采用直接输出的方式。同样我们也不太能在短时间内完整熟悉整个PDF
数据格式的标准,所以我们同样还是借助于库来生成PDF
文件,这里我们选择了pafmake
来生成PDF
,通过pdfmake
我们可以通过JSON
配置的方式自动排版和生成PDF
,相当于是从一种JSON
生成了另一种JSON
,而针对于Outline/Bookmark
的问题,我花了很长时间研究相关实现,最终选择了pdf-lib
来最终处理生成大纲。
与生成Word
的描述语言OOXML
不同,OOXML
中不包含任何用于直接渲染内容的绘图指令,实际上还是相当于静态标记,当用户打开docx
文件时会解析标记在用户客户端进行渲染。而创建PDF
时需要真正绘制路径PostScript-PDL
,是直接描绘文本、矢量图形和图像的页面描述语言,而不是需要由客户端渲染排版的格式,当PDF
文件被打开时,所有的绘图指令都已经在PDF
文件中,内容可以直接通过这些绘图指令渲染出来。
为了保持保持完整的跨平台文档格式,PDF
文件中通常还需要嵌入字体,这样才能保证在任何设备上都能正确显示文档内容,所以在生成PDF
文件时我们需要引入字体文件。需要注意的是,很多字体都不是免费使用的,特别是在公司中很多都是需要商业授权的,同样也有很多开源的字体,可以考虑思源宋体与江城斜宋体,这样就包含了normal
、bold
、italics
、bolditalics
四种格式的字体了,在服务端也可以考虑直接安装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/Bookmark
,Outline
通常就是我们说的大纲,通常会显示在打开的PDF
左侧。pdfmake
是不支持直接生成Outline
的,所以我们需要借助其他的库来实现这个功能,在调研了很长时间之后我发现了pdf-lib
这个库,可以用来处理已有的pdf
文件并且生成Outline
。在这个例子中生成PDF
之后的Outline
是通过id
系统来实现跳转的,实际上还有一个思路,使用pdfjs-dist
来解析并存储PDF
相应标题对应的页面与位置信息,然后再使用pdf-lib
将Outline
写入。此外,生成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.ts
和delta-to-pdf.html
,以及添加Outline
的pdf-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