当前位置: 首页 > news >正文

哪些网站怎么进网页qq邮箱登录入口

哪些网站怎么进,网页qq邮箱登录入口,微信开发者平台登录,用国旗做专利的是哪个网站深入在线文档系统的 MarkDown/Word/PDF 导出能力设计 当我们实现在线文档的系统时#xff0c;通常需要考虑到文档的导出能力#xff0c;特别是对于私有化部署的复杂ToB产品来说#xff0c;文档的私有化版本交付能力就显得非常重要#xff0c;此外成熟的在线文档系统还有很…深入在线文档系统的 MarkDown/Word/PDF 导出能力设计 当我们实现在线文档的系统时通常需要考虑到文档的导出能力特别是对于私有化部署的复杂ToB产品来说文档的私有化版本交付能力就显得非常重要此外成熟的在线文档系统还有很多复杂的场景都需要我们提供文档导出的能力。那么本文就以Quill富文本编辑器引擎为基础探讨文档导出为MarkDown、Word、PDF插件化设计实现。 文章中我们即将要聊到的每个转换器设计都有相关示例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能力的补充输出直接写入当前目录。 周末两天时间键盘都搓出火星子了才写完这篇文章我觉得还是很有必要一键三连一下的手动狗头。 描述 前段时间有位朋友跟我讲了个有趣的事他们公司的某个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: Recordstring, 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: Recordstring, 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的索引LinedeltaSet.push(currentZone, codeBlockLine);currentZone newZoneId;currentMode CODEBLOCK;}// 将Line置入当前要处理的ZonedeltaSet.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) PromiseOmitOutput, 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) PromiseOutput | null; // 处理函数 };接下来是入口的处理函数首先我们需要处理行格式因为行内格式可能会因为行格式出现不同的结果例如居中的行格式会导致行内格式解析成HTML标记这个标记是通过可变的tag对象来实现的我们的行格式是有可能会匹配到多个插件的所有的结果都应该保存起来同样的对于行内格式也是如此在处理函数的最后我们将结果拼接为字符串即可。 const parseZoneContent async (zoneId: string,options: { defaultZoneTag?: Tag; wrap?: string } ): Promisestring | 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[] [];// 不能影响外部传递的Tagconst 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的处理就会变的尤为重要特别是全量同步更新的场景下积累边界的测试用例就变得更加重要。 Word 在前边我们聊了作为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) PromiseLeafBlock | 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) PromiseLineBlock | null; // 处理函数 };接下来就是入口的Zone调度函数这里与之前的MD调度不同我们需要首先处理叶子节点也就是行内样式因为这里有一个特别需要关注的点是Paragraph对象是不能包裹Table对象的而此时如果我们需要实现一个块级结构那么外部是需要包裹Table而不是Paragraph也就是说此时我们的行内元素内容是会决定行元素的格式即A影响B那就先处理A所以此时是先处理行内元素并且单个块结构仅会匹配到一个插件所以相关的通用内容处理是需要封装到通用函数中的。 const parseZoneContent async (zoneId: string,options: { defaultZoneTag?: Tag } ): PromiseLineBlock[] | 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) {// ... 取行数据// 不能影响外部传递的Tagconst 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: WithDefaultOptionIRunOptions {};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: WithDefaultOptionIParagraphOptions {};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 {// 需要包裹组合嵌套BlockTablereturn 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同样非常需要单元测试来辅助我们保持功能的稳定性。 jcode 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的操作任何东西都可以通过绘制的方式来实现例如表格我们就可以直接通过画矩形的方式来绘制常用的库有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) PromiseLeafBlock | 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) PromiseLineBlock | null; // 处理函数 };入口的Zone调度函数与处理word的部分比较类似因为不存在单个块结构的嵌套关系同类型所有的格式配置都可以用同一个插件来实现所以这里同样是命中单个插件的形式此外同样是首先处理叶子节点因为叶子节点的内容会决定行元素的嵌套块格式。 const parseZoneContent async (zoneId: string,options: { defaultZoneTag?: Tag } ): PromiseContent[] | null {const { defaultZoneTag {} } options;const lines deltaSet.get(zoneId);if (!lines) return null;const target: Content[] [];for (let i 0; i lines.length; i) {// ... 取行数据// 不能影响外部传递的Tagconst 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-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 PromiseBuffer(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情况同样非常需要单元测试来辅助我们保持功能的稳定性。 jcode 每日一题 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
http://www.zqtcl.cn/news/795306/

相关文章:

  • 可以做配音兼职的网站产品网站怎样做外部链接
  • 如何制作网站效果图做外单要上什么网站
  • 网站开发预算编制网站可以制作ios
  • 强化网站建设网页翻译怎么弄出来
  • 长春火车站到龙嘉机场高铁时刻表视频网站建设公司排名
  • 武进网站建设代理商google官网下载
  • 简单网站开发流程图知乎怎么申请关键词推广
  • 成寿寺网站建设公司文登区做网站的公司
  • 建设一个网站用什么软件下载阿里外贸平台网站建设
  • 可信网站myeclipse网站开发
  • 做设计找素材的 网站有哪些网站建设实训个人总结
  • 浙江省建设厅继续教育官方网站网站做vr的收费
  • 建造网站 备案苏州手机网站设计
  • 做外贸卖小商品是哪个网站手机首页设计
  • 大连网站制作公司营销策划公司有哪些职位
  • 2019深圳网站设计公司排名网站设计的思想
  • 试客那个网站做的好seo管理平台
  • 增加网站关键词库网盟推广合作
  • 企业门户网站内容建设濮阳网络培训基地
  • 做亚马逊运营要看哪些网站免费咨询电脑问题
  • 如何用html制作网站app开发要多少钱
  • 中国搜索提交网站信息网络犯罪
  • 网站服务器做下载链接分销平台系统源码
  • 网站管理助手建站沈阳专业网站建设企业
  • 企业网站开发公司大全建筑工程培训
  • 免费网站开发模板云南省网站开发软件
  • dede小游戏php网站源码广州网站vi设计报价
  • 邯郸建设局网站资质申报wordpress 前端 插件
  • 关于asp_sql网站开发的书籍小程序跳转网页方法
  • 昆明网站开发公司电话建设手机银行的网站