Skip to content

Node 环境使用 Puppeteer 实现调用 WPS JS-SDK

前言

在引入 WPS JS-SDK 后,我们发现并不能直接和现有数据进行融合。

因为业务人员之前编辑合同模板,预留的变量是 { { xxx } } 这种形式的,通过字符串进行匹配替换。而 WPS 则是引入了 公文域/书签/内容控件 等概念,用来进行文本替换。

又因为实际业务需求,我们需要实现全自动的合同模板生成合同文件。

所以我使用 Node 环境做了一个中间层,兼容现有架构。

方案

初始的方案如下:

获取模板文档内容 -> 文档内容变量替换 -> 保存为合同文件

虽然可以通过模板生成合同文档,但是文档的格式,例如文档方向、页眉页脚,会出现丢失的情况。

排查后发现,这是因为文档格式是存在文档上的,获取不到。

思考后,决定通过模板副本保留文件格式,再覆盖其内容,得到所需的合同文件。

修改后如下:

更新模板 -> 更新模板副本 -> 获取模板文档内容 -> 文档内容变量替换 -> 覆盖模板副本内容 -> 导出 pdf 文件

实现

页面引入 WPS JS-SDK

这里可以看看我之前的博客 Vue 引入 WPS JS-SDK

更新模板副本和 html 代码

退出用户页面前,调用 WPSGetHtmlData 方法,获取文档 html 代码,传给后端。

js
// 获取指定区域的带格式 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 中文文档

Puppeteer 官网文档

安装 Puppeteer

shell
npm i puppeteer

使用 Puppeteer

js
const puppeteer = require("puppeteer");

导出 pdf

PuppeteerPage 对象有两个方法:

  1. Page.evaluate(pageFunction, args)
    在页面 window 上下文中执行 pageFunction 方法

  2. Page.exposeFunction(name, pptrFunction)
    pptrFunction 方法以 name 的值挂载到页面 window 对象上, 在页面中调用该方法,将在 Node 上下文中执行。

有这两个方法,我们就能实现页面与 Node 的交互。

Node 端页面端 需要做什么

Node 端 需要响应其他系统的调用,并返回可用的 pdf 文件链接。
页面端 需要实现 WPS JS-SDK 的加载调用,并回调 Node 端 方法,传递 pdf 文件链接。

Node 端页面端 能够做什么

Node 端 只能使用 Pupppeteer 加载对应页面,之后便会丧失控制权,无法监控流程节点,也无法知道页面执行成功与否。
页面端 只能监控并完成文档的覆盖与导出,并不能知道请求是否成功返回 pdf 链接。

Node 端

js
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()
  });
}

页面端

html
<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>

总结

虽然实现了需求,但是存在几个问题:

  1. 总体执行时间长,顺利的情况下,也要 4 到 5 秒;
  2. 页面报错不易捕捉。

总的来说,还是能够接受的,毕竟是后端系统服务之间的调用,用户感知小。

希望 WPS 早日推出 Node 版本,开发也就不用这样脱裤子放屁了。

为什么不用 JSDOM ?

因为最新版的 JSDOM(v22.1.0) 没有实现页面性能相关接口,导致 WPS JS-SDK 加载报错。

为什么不直接使用 WPS 提供的格式转换服务?

因为不能满足需求,我们需要替换模板中的变量,WPS 只能转换格式。如果我们直接新建文件用替换后的 html 代码覆盖,那就会出现文档样式丢失的情况。

Released under the MIT License.