本期作者
顾伊凡
哔哩哔哩开发工程师
2021年加入B站,负责UP主创作激励、收益中心、电子签约平台前端建设。
本文将从业务场景与技术实现等角度对“web端pdf编辑能力”进行基本的介绍。
01 背景
B站电子签约平台业务简介
正如其名,该平台用于B站各场景下的“电子签约”业务,相当于把大量原本需在线下完成的纸质合同签约过程移至线上。
其中一种典型的签约流程示意如下:
其中核心的就是第三步,也是耗时、易出错的一步。
常见pdf合同模板如下(局部):
是的,pdf支持表单。这种“含表单的pdf”就构成了电子签约平台的“合同模板”,各签约方在其基础上填写并经过电子签章后,就形成了具有法律效益的电子合同。
使用市面上pdf编辑器的问题
市面上已经存在一些支持编辑pdf的软件,如Adobe Acrobat Pro DC、福昕pdf编辑器等,但在我们的业务场景下,对于这些编辑器的使用中出现了诸多问题:
收费。
现成的编辑器无法满足特定业务的定制化需要,学习成本很高。接入B站签约平台的业务方不但需要学习编辑器本身的基础操作,还需学习我们定义的各类规则(如表单命名规则、必填规则、盖章位置规则、oa字段规则等)。
样式(如字体、表单长度、边框粗细等)难以统一。
编辑过程本身不受签约平台约束,如有不符合规范的操作会导致pdf合同模板无法被正确识别,并可能存在其它风险。
业务方内部的后续交接、维护成本较高。
这里只列举了较常遇到的,其它各类问题还有很多。
02 业务场景定制的pdf编辑器
其实从更基本的角度去看,以上都是“标准化”能力欠缺所带来的衍生问题。
随着签约平台业务方和业务规模的增加,提供一定的标准化能力是有必要的,否则可能使我们的维护成本增长失速并增加更多不稳定因素。
一方面,“标准化”本身能通过统一的约束去提高整个系统的确定性和稳定性。另一方面,将“标准化”规则以及相关的复杂业务逻辑本身封装在平台内部,终仅呈现给用户简易、有限且必要的功能,能明显提升用户的接入与使用体验。即一种好的标准设计应当是融合在产品之中的,而不应该完全依赖过于复杂的sop。
这些可以通过“符合B站签约场景的web端pdf编辑器”做到,这样一来签约流程更新如下:
下图就是终实现的该编辑器:
其中主要包含了:
画布面板。用户可在此进行在位编辑,如拖拽、改变大小、编辑默认值、删除、框选、复制粘贴等。
左侧元件list面板。提供基本的可编辑元件,如文本表单、checkbox、select、普通文本,以及业务定制元件(如企业名称、手机号、盖章位置、oa审核信息等)。用户从此处把它们拖拽至画布。
右侧属性编辑面板。用于进行编辑详情的实时展示,以及各参数的微调。
预览面板。预览当前编辑态下对应的实际pdf样式。
(其它页面还包含了更多B站签约平台下的特定业务配置,也会反应到终pdf中,这里不展开)
如此一来,使用签约平台的运营同学在新建合同模板时,几乎不再有学习成本,当前业务线下原本直接暴露给运营同学的海量规则全部被封装了起来,运营同学只需进行一些简单操作即可完成一份新的合同模板。
当然,该编辑器还是版,仅实现了基本能力,后续更多功能会慢慢迭代进来。
03 方案设计
3.1 技术可行性拆解
在具体设计前,我们先对部分关键技术点做一下可行性分析,有一个大致概念。
pdf在线预览 √
其实通过<iframe>或<embed>就可以利用浏览器自带的能力进行pdf预览,但若需要更灵活地进行预览时的控制,可以使用pdf.js。
pdf编辑时的交互与ui层 √
相当于实现一个简易的画板,提供基本的可视化编辑能力,画布本身的渲染基于canvas或常规html元素皆可。这里由于不需要复杂的图形编辑,考虑基于常规html元素。
pdf生成 √
jsPDF和pdf-lib两个js库可以实现(其它冷门库暂不考虑)pdf的生成能力,包括插入acroForm(标准pdf表单)。
目前来看jsPDF不支持在已有pdf文件基础上进行编辑,只能从0开始创建pdf。而pdf-lib支持在已有pdf文件上编辑,符合B站电子签约平台场景,且文档相对整洁,故采用pdf-lib。但它毕竟不是很热门的库,可能会存在一些坑需逐步踩过去。
也可考虑基于wasm去实现,这样就不局限于js库了,选择面更广,但对于大部分前端工程师来说开发成本会提高。
3.2 架构概览
基于以上可行性分析后,结合B站签约场景业务需求,便得出了如下架构(下文会详细介绍):
3.3 Schema
在设计的初阶段会遇到以下几个主要问题:
编辑中画布上的状态如何处理
编辑后如何存储
如何将画布上的编辑结果转换为pdf结果
因而抽出了“schema”这层。schema是对所有编辑信息的描述,可以理解为:原始pdf + schema数据 == 合成后的pdf。
schema的作用如下:
画布基于它渲染、pdf基于它合成、存储的格式也是它,且可以作为全局共用的单一store,便于实现数据这一层上的收敛,降低复杂度。
几乎无需额外开发成本即可实现二次编辑的能力,只需重新解析原始pdf + schema即可,解析过程完全复用新建的逻辑(而若直接读取生成后的pdf并进行编辑,一是需额外开发解析pdf的能力导致成本提高,二是出现一致性问题的概率大幅提升)。
-
此外,诸如“用户误关浏览器”场景下的“工作区恢复”能力也类似,易于实现。
回撤、重做能力变得更易实现。
可扩展性。json结构本身是可以灵活扩展的。
过往的schema可以沉淀为一个“模板库”,后续新建类似编辑时,可直接选取一种schema,在其基础上修改,提高效率。
3.4 数据流
因为是编辑器这种典型的会频繁操作数据的场景,加上可能会涉及较多模块,“肆意妄为”的数据流会导致项目后期复杂度指数级提升,愈发难以追溯和管控。
故采用单一store(主要存储schema)、单向数据流,且需确保元件面板、属性面板、画布、pdf生成器之间无任何直接的数据流、事件触发机制或其它强耦合机制,都只对store负责。且该设计下也支持后续灵活新增其它任意面板模块,或是删除、替换已有面板(即实现任意面板的“可插拔”能力),其复杂度依然是稳定可控的。
3.4.1 回撤与重做功能
定义所有原子操作(正操作)以及相应的逆操作逻辑(使得“正操作 + 逆操作 = 原状态”),运行时记录每一步操作,存于list中,回撤时执行对应逆操作。
因为基于单一store,故可以每一步操作后形成新的全局state对象,用一个list存储所有state对象,指针指向的即为当前state,通过指针左移/右移实现回撤/重做功能。
相比之下,方案一成本、不确定性更高,方案二简单、稳定,故采用方案二。方案二可结合使用immutable.js,基于它每次更新都可以方便地得到新的state,且由于结构共享,list可以很长也无需担心占用过大内存。
3.4.2 vuex plugin
基于该思路,我实现了一个简易的vuex plugin以实现对于任意immutable state的回撤重做功能,以下是代码中的使用示例:
// store配置示例
import Vue from 'vue'
import Vuex from 'vuex'
import {fromJS} from 'immutable';
import undoRedoPlugin, {undoRedoHistory, stateWrapper} from "./plugins/vuex-undo-redo-plugin";
Vue.prototype.$undoRedoHistory = undoRedoHistory;
export default new Vuex.Store({
state: stateWrapper({
fields: fromJS([]), // 画布上所有域的信息。fromJS用于生成初始immutable对象
scale: 1,
editingFieldIndex: -1,
// ...
}, ['fields']) // 此时对fields的所有操作可以被回撤/重做
// ...
plugins: [
undoRedoPlugin
]
})
// 回撤/重做操作示例
// ...
handleUndo() {
this.setFields(this.$undoRedoHistory.undo("fields"));
},
handleRedo() {
this.setFields(this.$undoRedoHistory.redo("fields"));
},
// ...
同时考虑部分特定场景下触发的更新不希望写入state history,也实现了该“transient”方法:
// ...
updateField(val, propName) {
const newFields = this.fields.setIn(
[this.editingFieldIndex, propName],
val
);
this.setFields(newFields);
},
handleInput(...args) {
// 因为每输入一个字符就会触发onInput,所以不计入undo/redo历史
this.$undoRedoHistory.transient(() => {
this.updateField(...args);
});
},
handleBlur(e, propName) {
// 这时候统一计入undo/redo历史
this.updateField(e.target.value, propName);
},
// ...
3.5 画布
自底向上可以分为三层:
原始pdf渲染层:
直接把pdf的objectURL放入iframe也可以渲染出pdf,但:
不同浏览器的渲染结果有差异,不可控
iframe内的pdf可以独自上下滚动,这样我们的画布也需配合其滚动,实现成本较高
所以这里基于pdf.js是合适的选择,整个渲染结果都是可控的,方便我们在其之上叠加画布层以及进行无误差的坐标计算。
画布渲染层:
解析schema后渲染对应内容,因为不用实现一些复杂图形、复杂操作,所以这里无需使用canvas,直接基于普通html元素去实现即可。
普通文本表单可使用div + contenteditable属性实现在位编辑。
画布编辑层:
包括鼠标动作监听、坐标计算、编辑框渲染、逻辑处理等。任何操作都需实时更新schema,以保持所有面板的同步。
且应既支持在位编辑又支持属性面板上的详细编辑,前者为了提高操作效率,后者为了查看实际参数、微调细节。
3.6 pdf生成器
pdf生成器主要是做schema的解析并基于pdf-lib绘制生成后的pdf。
pdf-lib的常规使用阅读 https://pdf-lib.js.org/docs/api/ 即可,这里不展开。
使用过程中需注意以下几点:
需加载中文字体库才能支持写入中文内容;
由于中文字体包通常都很大,pdf-lib会将其全部整合进生成的pdf内,导致pdf体积骤增,可使用Fontmin动态地对字体包进行压缩(使字体包内仅保留使用到的中文字符),大幅缩小体积;
pdf-lib在这方面的文档不太清晰,写入中文容易导致报错,以下为验证有效的写法示例,供参考:
import fontkit from '@pdf-lib/fontkit';
export const generatePDF = async function(fileBuffer, fields) {
const pdfDoc = await PDFDocument.load(fileBuffer);
pdfDoc.registerFontkit(fontkit);
const fontBytes = await fetch(`${BASE}/microsoft_yahei_tahoma.TTF`).then(res => res.arrayBuffer());
const customFont = await pdfDoc.embedFont(fontBytes);
// ...
textField.updateAppearances(customFont);
textField.addToPage(pages[pageIndex], {
font: customFont,
// ...
});
textField.setFontSize(size);
textField.updateAppearances(customFont);
// ...
}
生成pdf时如有多个表单项同名则会报错,其实pdf格式本身并未做此限制,是pdf-lib做的,在我们的业务场景下也需支持多个同名表单项。解除这个限制的方式是在pdf-lib源码中找到“throw new FieldAlreadyExistsError(partialName)”并删除(可以用patch-package使得对node_modules中源码的改动持久生效)。
画布上为了更方便地计算拖拽、resize时的坐标/大小等数据,此时y轴定义的方向是从上往下的(也与一般前端开发的习惯相同),pdf-lib绘制时的y轴是从下往上的,这里要做一下转换。
3.7 pdf预览
预览模块是为了让用户及时查看当前编辑状态下生成的真正pdf长什么样,此时需要先走一遍pdf生成器,然后将生成的pdf转换成objectURL直接放在iframe内即可,这里我们只需一个简单的预览功能,故可不用pdf.js。
如果是在chrome中,你可能会发现iframe中的pdf还多了左侧的缩略图目录,如果觉得比较占用空间想去掉,可以在url后面加上#toolbar=0
04 结语
对于“web端pdf编辑能力”,本文提供了一种相对完整且轻量的实现方式,如果你也有类似诉求,希望能对你有所帮助~