Node 环境使用 Puppeteer 实现调用 WPS JS-SDK
前言
在引入 WPS JS-SDK
后,我们发现并不能直接和现有数据进行融合。
因为业务人员之前编辑合同模板,预留的变量是 { { xxx } }
这种形式的,通过字符串进行匹配替换。而 WPS
则是引入了 公文域/书签/内容控件
等概念,用来进行文本替换。
又因为实际业务需求,我们需要实现全自动的合同模板生成合同文件。
所以我使用 Node
环境做了一个中间层,兼容现有架构。
方案
初始的方案如下:
获取模板文档内容 -> 文档内容变量替换 -> 保存为合同文件
虽然可以通过模板生成合同文档,但是文档的格式,例如文档方向、页眉页脚,会出现丢失的情况。
排查后发现,这是因为文档格式是存在文档上的,获取不到。
思考后,决定通过模板副本保留文件格式,再覆盖其内容,得到所需的合同文件。
修改后如下:
更新模板 -> 更新模板副本 -> 获取模板文档内容 -> 文档内容变量替换 -> 覆盖模板副本内容 -> 导出 pdf 文件
实现
页面引入 WPS JS-SDK
这里可以看看我之前的博客 Vue 引入 WPS JS-SDK。
更新模板副本和 html
代码
退出用户页面前,调用 WPS
的 GetHtmlData
方法,获取文档 html
代码,传给后端。
// 获取指定区域的带格式 HTML 数据
async getHtmlData() {
const Range = await this.instance.Application.ActiveDocument.Content;
const htmlInfo = await Range.GetHtmlData();
return htmlInfo.HTML;
}
//调用更新接口
async updateHtmlData() {
const htmlData = await this.getHtmlData()
// 调用后端接口,后端在更新 html 代码的同时,更新模板副本
// 如果服务器硬盘空间紧张,也可以在后面调用 Node 接口时,临时复制一个副本出来
axios.post("url", { htmlData })
}
Node
引入 Puppeteer
这里是我使用 Nest
框架,在 Node
环境内实现的。
安装 Puppeteer
npm i puppeteer
使用 Puppeteer
const puppeteer = require("puppeteer");
导出 pdf
Puppeteer
的 Page
对象有两个方法:
Page.evaluate(pageFunction, args)
在页面window
上下文中执行pageFunction
方法Page.exposeFunction(name, pptrFunction)
pptrFunction
方法以name
的值挂载到页面window
对象上, 在页面中调用该方法,将在Node
上下文中执行。
有这两个方法,我们就能实现页面与 Node
的交互。
Node 端
和 页面端
需要做什么
Node 端
需要响应其他系统的调用,并返回可用的 pdf
文件链接。页面端
需要实现 WPS JS-SDK
的加载调用,并回调 Node 端
方法,传递 pdf
文件链接。
Node 端
和 页面端
能够做什么
Node 端
只能使用 Pupppeteer
加载对应页面,之后便会丧失控制权,无法监控流程节点,也无法知道页面执行成功与否。页面端
只能监控并完成文档的覆盖与导出,并不能知道请求是否成功返回 pdf
链接。
Node 端
const puppeteer = require('puppeteer');
// ContractDto 类构造
// class ContractDto {
// fileId: string; // 文档id
// htmlUrl: string; // html 代码文件地址
// }
// 提取wps文档的html代码
async getPuppeteerWpsFileHtmlContent(res: Response, contractDto: ContractDto) {
const fileId = contractDto.fileId
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
// 打开挂载了WPS JS-SDK 的公共页面
await page.goto("url");
// 需要获取登录权限,可以模拟登录,成功后在登录页面判断跳转
// const username = await page.$('input[name=用户名]');
// const password = await page.$('input[name=密码]');
// await username.type('');
// await password.type('');
// const submit = await page.$('.login_button');
// await submit.click();
// wps 加载成功回调
await page.exposeFunction('isWpsReady', async () => {
// 模拟点击设置按钮,覆盖 html 代码
const btnBeforeSetHtmlData = await page.$('#beforeSetHtmlData');
await btnBeforeSetHtmlData.click();
// 模拟点击导出按钮,导出 pdf 文件
const exportPdfFile = await page.$('#exportPdfFile');
await exportPdfFile.click();
});
// 获取 html 字符串
const htmlData = await this.GenerateWpsContrat(contractDto);
await page.exposeFunction('getHtmlData', async () => htmlData);
// 设置 html 回调
await page.exposeFunction('setHtmlData', async () => {
console.log('Wps 设置 html 成功');
});
// 导出 pdf 回调
await page.exposeFunction('exportPdfFile', async (pdfUrl) => {
console.log('pdf 导出成功:', pdfUrl.url);
res.send(pdfUrl.url || "获取失败")
await browser.close()
});
// 关闭浏览器
await page.exposeFunction('closeBrower', async () => {
await browser.close()
});
}
页面端
<template>
<div>
<div class="web-office" id="web-office"></div>
<div id="beforeSetHtmlData" @click="() => beforeSetHtmlData()">
设置文件html
</div>
<div id="exportPdfFile" @click="() => exportPdfFile()">导出pdf</div>
</div>
</template>
<script>
import WebOfficeSDK from "@/utils/webOfficeSdk.js";
export default {
name: "Index",
data() {
return {
instance: {},
token: "",
};
},
computed: {
fileId() {
return this.$route.query.fileId;
},
},
mounted() {
this.init();
},
methods: {
async init() {
// 注册wps-SDK
this.instance = WebOfficeSDK.init({
mode: "simple",
mount: "#web-office",
appId: "",
officeType: WebOfficeSDK.OfficeType.Writer,
fileId: this.fileId,
token: this.token,
});
// 用来检测文件是否打开成功
this.instance.ApiEvent.AddApiEventListener("fileOpen", (data) => {
if (data.success) {
console.log("WPS 文件打开成功");
} else {
console.log("WPS 文件打开失败");
}
});
await this.instance.ready();
// puppeteer 方法,wps 加载成功回调
await window.isWpsReady();
},
// 获取 HTML 数据
async beforeSetHtmlData() {
// puppeteer 方法,获取 html 字符串
const htmlData = await window.getHtmlData();
this.setHtmlData(htmlData);
},
// 设置指定区域的带格式 HTML 数据
async setHtmlData(htmlData) {
const Range = await this.instance.Application.ActiveDocument.Content;
await Range.PasteHtml({
HTML: htmlData,
});
// puppeteer 方法,设置 html 回调
await window.setHtmlData();
},
async exportPdfFile() {
// 导出 PDF,并获取导出后的 url,这个 url 就是在线的文档链接,所以可以直接使用
const pdfUrl =
await this.instance.Application.ActiveDocument.ExportAsFixedFormat(); // 默认导出 PDF,所以可以不传参
// puppeteer 方法,导出 pdf 回调
await window.exportPdfFile(pdfUrl);
console.log(pdfUrl);
},
},
};
</script>
<style lang="scss" scoped>
.web-office {
height: 600px;
width: 100%;
}
</style>
总结
虽然实现了需求,但是存在几个问题:
- 总体执行时间长,顺利的情况下,也要 4 到 5 秒;
- 页面报错不易捕捉。
总的来说,还是能够接受的,毕竟是后端系统服务之间的调用,用户感知小。
希望 WPS
早日推出 Node
版本,开发也就不用这样脱裤子放屁了。
为什么不用 JSDOM
?
因为最新版的 JSDOM
(v22.1.0) 没有实现页面性能相关接口,导致 WPS JS-SDK
加载报错。
为什么不直接使用 WPS
提供的格式转换服务?
因为不能满足需求,我们需要替换模板中的变量,WPS
只能转换格式。如果我们直接新建文件用替换后的 html
代码覆盖,那就会出现文档样式丢失的情况。