评论

如何使用微信小程序·云开发的Node.js云函数生成Word文档(2021-10-15更新)

借助officegen,使用小程序·云开发的云函数,实现Word、Excel、PowerPoint文件的生成,并上传到云存储中。

编者按

近期一个云开发项目有生成Word文档的需求,经过搜索,发现并没有小程序·云开发有关生成word文档的案例,因为本人还是本科生且非科班出身,一路摸着石头过河,遇到了不少困难,期间还试图向社区的大佬们求助;花了两天时间才搞定这一百行代码,现在分享给大家。

代码有些糙,希望大佬们不要嫌弃。

一、安装云函数依赖officegen、fs

工欲善其事必先利其器,我们知道云函数代码运行在云端Node.js环境中,因此,理论上来说,Node.js能做的事情,小程序·云开发的云函数基本上也能做到。officegen是Github上一款生成微软Office文档的工具,包括.docx、.xlsx、.pptx三种文件,由于我只用了.docx,本文将以Word文件为例。

https://github.com/Ziv-Barber/officegen

1. 首先我们在微信开发者工具中 新建一个云函数 => 右键云函数名 => 在终端中打开

2. npm安装依赖officegen和fs,为了方便本地调试云函数,我们这里也安装wx-server-sdk。

代码如下,请逐个安装,如果安装有问题,可以自行搜索“npm”或“npm taobao 镜像” ;这里不再赘述。

    npm i officegen
    npm i fs
    npm i wx-server-sdk


3. 在云函数index.js开头写下以下代码,引用我们刚刚安装的包。

    const cloud = require('wx-server-sdk')
    const officegen = require('officegen');
    const fs = require('fs');
    const docx = officegen('docx');


二、创建Word文档的内容

文档地址: https://github.com/Ziv-Barber/officegen/blob/master/manual/docx/README.md

1. 首先我们根据文档定义(Ctrl CV)两个函数

//文档生成完成后调用,后来其实发现没啥用

    // Officegen calling this function after finishing to generate the docx document:
    docx.on('finalize'async function (written{
      console.log('Finish to create a Microsoft Word document.')
    })

    //生成文档出现问题时调用

    // Officegen calling this function to report errors:
    docx.on('error'function (err{
      console.log(err)
    })


2. 创建段落API: docx.createP(options)

    //声明一个创建段落的变量p0bj

	let pObj = docx.createP(options)

//创建一个段落并插入文本

    pObj = docx.createP({
      align: 'center'    //文字对齐方式,center、justify、right;默认为left
      indentLeft = 1440; // 段落缩进 Indent left 1 inch
      indentFirstLine = 440; // 首行缩进
    })
    pObj.addText('你要插入的文字,这里可以时变量', {
      bold: true,    //是否加粗,默认false
      font_face: 'KaiTi',    //字体,这里以“楷体为例”,如果填写了打开文档的电脑没有安装的字体名称,将使用默认字体。能不能用中文,我没试过。
      font_size: 19,    //字号
      color: '595959'   //文字颜色
    });

上述例子外,还可以添加下划线、设置斜体、超链接、分页等;还可以编辑页眉和页脚、插入图片等。详见后续代码示例或officegen文档。


3. 插入图片

这里以插入小程序码为例,直接上代码。

要注意的是officegen似乎不支持以buffer形式插入图片,因此要先将图片保存。

//首先定义一个用于保存小程序码图片的函数

    //save QR
    saveFile = function (filePath, fileData{
      return new Promise((resolve, reject) => {
        const wstream = fs.createWriteStream(filePath);
        wstream.on('open'() => {
          const blockSize = 128;
          const nbBlocks = Math.ceil(fileData.length / (blockSize));
          for (let i = 0; i < nbBlocks; i += 1) {
            const currentBlock = fileData.slice(
              blockSize * i,
              Math.min(blockSize * (i + 1), fileData.length),
            );
            wstream.write(currentBlock);
          }
          wstream.end();
        });
        wstream.on('error'(err) => {
          reject(err);
        });
        wstream.on('finish'() => {
          resolve(true);
        });
      });
    }

//要获取小程序码,首先要修改云函数config.json文件中的云调用权限

    {
      "permissions": {
        "openapi": [
          "wxacode.getUnlimited"
        ]
      }
    }

//在云函数main中获取小程序码

    //https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/qr-code/wxacode.get.html
    const result = await cloud.openapi.wxacode.getUnlimited({
      page: 'pages/check/check',    //小程序页面地址,必须是线上版本中存在的页面的完整地址
      scene: '',    //小程序码参数
      width: 240,    //小程序码的宽度(是个正方形)
    })
    const QRcode = result.buffer
    await saveFile('/tmp/qr.jpg', QRcode); // 这里的fileData是Buffer类型,关于路径会在第三部分生成Word文件中解释。

//将图片插入到文档中

    pObj = docx.createP()    //创建段落
    pObj.options.indentFirstLine = 440;     //首行缩进
    pObj.addImage('/tmp/qr.jpg', {  //图片文件路径
      cx: 140,    //长度
      cy: 140    //宽度
    });



三、生成Word文件

文档内容完成后,就可以生成文档了。officegen似乎只能生成文件,没有文件buffer的接口,而要上传到小程序·云开发的云存储中,只能使用Buffer或fs.ReadStream,怎么办呢?先把文件保存下来再读取呗。

首先提一下云函数运行环境

https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/functions/mechanism.html

云函数运行在云端 Linux 环境中,一个云函数在处理并发请求的时候会创建多个云函数实例,每个云函数实例之间相互隔离,没有公用的内存或硬盘空间。云函数实例的创建、管理、销毁等操作由平台自动完成。每个云函数实例都在 /tmp 目录下提供了一块 512MB 的临时磁盘空间用于处理单次云函数执行过程中的临时文件读写需求,需特别注意的是,这块临时磁盘空间在函数执行完毕后可能被销毁,不应依赖和假设在磁盘空间存储的临时文件会一直存在。如果需要持久化的存储,请使用云存储功能。

因此,我们将文件保存在/tmp路径下,文件名随便起,这里我取为exampl.docx。生成文档的代码如下:

  // Let's generate the Word document into a file:
  let out = fs.createWriteStream('/tmp/example.docx')
  // Async call to generate the output file:
  docx.generate(out)

理论上来说,我们文档生成完毕后,通过fs.ReadFileStream读取文件调用cloud.uploadFile()即可上传到云存储

    const fileStream = fs.createReadStream('/tmp/example.docx')
    return await cloud.uploadFile({
      cloudPath: '/tmp/example.docx',
      fileContent: fileStream,
    })

而在测试过程中我发现,云端测试时,云函数调用超时。而后使用本地调试查看问题出在何处。

云函数本地调试的方法不再赘述,看这里即可。https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/functions/local-debug.html

通过本地调试,发现cloud.uplodaFile()的网络请求始终时挂起(pending)状态,没有传输数据。

经过一天的调试,通过监听文件,发现officegen生成文件完成,执行了我们开头复制粘贴的生成文档后执行的docx.on("finalize",)函数,打印文档生成成功的日志后,仍有文件变动,也就是说,文件并没有生成完毕。这就导致了后续步骤的失败。

当时调试的界面我没有保存,就贴一下fs监听文件的代码吧。

  let watcherObj = '/tmp/example.docx'
  //eventType 可以是 'rename' 或 'change'; 当改名或出现或消失的时候触发rename; recursive:是否监听到内层子目录,默认false;
  try {
      let myWatcher = fs.watch(watcherObj,{encoding:'utf8',recursive:true},(event,filename) => {
          if(event == 'change'){
              console.log("触发change事件")
          }
          console.log(event)
          //encoding:文件名编码格式,buffer、默认:utf8等;filename有可能为空
          if(filename){
              console.log('filename: ' + filename)
          }
      })
      //change 事件会触发多次  
      myWatcher.on('change',function(err,filename){  
          console.log(filename + '发生变化');  
      }); 
      //50秒后 关闭监视  
      setTimeout(function(){  
          myWatcher.close()
      },5000);
  } catch (error) {
      console.log('文件不存在!!')
  }

为解决这一问题,我最先想到了await,结果发现await对officegen生成文档的接口并不起作用;最终我用了最原始的笨办法:用setTimeout等一会儿再读取文件,大佬们有更好的解决方案还请赐教。

  return new Promise((resolve, reject) => {
    setTimeout(async function () {
      let data = fs.readFileSync('/tmp/example.docx');
      let bufferData = new Buffer.from(data, 'base64');
      console.log(bufferData);
      setTimeout(async function () {
        resolve(await cloud.uploadFile({
          cloudPath: varpath,
          fileContent: bufferData,
        }));
      }, 1000);    //等文件再读1秒
    }, 6300);    //等文件再写一会儿。根据自己的需求调试后确定等待时长,要预留出一定时间确保文档完全生成完毕。
  })    //最终返回内容为文件云存储中的CloudID。


四、完整核心代码

const cloud = require('wx-server-sdk')
const officegen = require('officegen');
const fs = require('fs');
const docx = officegen('docx');
cloud.init({
  env: '这里填入你的云环境'
})
// Officegen calling this function after finishing to generate the docx document:
docx.on('finalize'async function (written{
  console.log('Finish to create a Microsoft Word document.')
})
// Officegen calling this function to report errors:
docx.on('error'function (err{
  console.log(err)
})
//save QR
saveFile = function (filePath, fileData{
  return new Promise((resolve, reject) => {
    const wstream = fs.createWriteStream(filePath);
    wstream.on('open'() => {
      const blockSize = 128;
      const nbBlocks = Math.ceil(fileData.length / (blockSize));
      for (let i = 0; i < nbBlocks; i += 1) {
        const currentBlock = fileData.slice(
          blockSize * i,
          Math.min(blockSize * (i + 1), fileData.length),
        );
        wstream.write(currentBlock);
      }
      wstream.end();
    });
    wstream.on('error'(err) => {
      reject(err);
    });
    wstream.on('finish'() => {
      resolve(true);
    });
  });
}
// 云函数入口函数
exports.main = async (event, context) => {
  var time = new Date()
  var filePath = 'exportVoluntaryData'
  var fileName = "zyzm" + Date.parse(new Date()) + '.docx'
  var varpath = filePath + '/' + fileName
  //get QRcode
  const result = await cloud.openapi.wxacode.getUnlimited({
    page: 'pages/check/check',
    scene: item._id,
    width: 240,
  })
  const QRcode = result.buffer
  await saveFile('/tmp/qr.jpg', QRcode);
  // Add a Footer:
  var footer = docx.getFooter().createP();
  footer.addText('XXXX证明_' + item._id, {
    font_size: 10
  });
  footer = docx.getFooter().createP();
  footer.addText(time.toString(), {
    font_size: 10
  });
  //下方开始文档每一页的循环
  for (var i in item.volunteerInfo) {
    //标题
    let pObj = docx.createP({
      align: 'center'
    })
    pObj.addText('XXX证明', {
      bold: true,XXX
      font_face: 'KaiTi',
      font_size: 19,
      color: '595959'
    });
    //此处省略了一些正文内容
    pObj = docx.createP()
    pObj.options.indentFirstLine = 440;
    pObj.addText('微信扫描下方小程序码,可核验此证明。', {
      font_face: 'FangSong',
      font_size: 12,
      color: '595959',
      italic: true,
    });
    pObj = docx.createP()
    pObj.options.indentFirstLine = 440;
    pObj.addImage('/tmp/qr.jpg', {
      cx: 140,
      cy: 140
    });
    pObj = docx.createP()
    pObj = docx.createP({
      align: 'right'
    })
    pObj.addText('落款', {
      font_face: 'FangSong',
      font_size: 15,
      color: '595959'
    });
    if (i != ((item.volunteerInfo).length - 1)){
      docx.putPageBreak()   //分页
    }
  }
  // Let's generate the Word document into a file:
  let out = fs.createWriteStream('/tmp/example.docx')
  // Async call to generate the output file:
  docx.generate(out)
  return new Promise((resolve, reject) => {
    setTimeout(async function () {
      let data = fs.readFileSync('/tmp/example.docx');
      let bufferData = new Buffer.from(data, 'base64');
      console.log(bufferData);
      setTimeout(async function () {
        resolve(await cloud.uploadFile({
          cloudPath: varpath,
          fileContent: bufferData,
        }));
      }, 1000);
    }, 6300);
  })
}


本人非计算机相关专业本科生,且本文大部分内容为手打,难免会有差错和疏漏,还请各位指教。

希望本文对你有所帮助。


Soochow University. HaoChen.

2020年2月


======= 2021-10-15更新 =======

经过一段时间的使用,上述内容主要存在两点问题:(1)难以判断文件何时生成完毕;(2)连续调用生成文档时,若上一个云函数实例未被销毁,会出现文件内容重复和错乱的问题。

前一段时间进行了更新,因为工作学习忙碌,此次暂不做详解,代码如下。

  1. 入口文件index.js
// 云函数入口文件
delete require.cache[require.resolve('officegen')];
const cloud = require('wx-server-sdk')
var office = require('office.js');
//https://github.com/Ziv-Barber/officegen/blob/master/manual/docx/README.md
cloud.init({
  env'sudaxmt1900'
})
const db = cloud.database()
const _ = db.command
// 云函数入口函数
exports.main = async (event, context) => {
    return await office.genWord(event);
}
  1. office.js
const cloud = require('wx-server-sdk')
const fs = require('fs');

function delDir(path{
  console.log("delete Dir")
  let files = [];
  if (fs.existsSync(path)) {
    files = fs.readdirSync(path);
    files.forEach((file, index) => {
      let curPath = path + "/" + file;
      if (fs.statSync(curPath).isDirectory()) {
        delDir(curPath); //递归删除文件夹
      } else {
        fs.unlinkSync(curPath); //删除文件
      }
    });
    // fs.rmdirSync(path); // 删除文件夹自身
  }
}
readDocx_fs = function (path{
  return new Promise((resolve, reject) => {
      fs.readFile(path,(err,data)=>{
        resolve(data);
        reject(err);
      })
  })
}
//save QR
saveFile = function (filePath, fileData{
  return new Promise((resolve, reject) => {
    const wstream = fs.createWriteStream(filePath);
    wstream.on('open'() => {
      const blockSize = 128;
      const nbBlocks = Math.ceil(fileData.length / (blockSize));
      for (let i = 0; i < nbBlocks; i += 1) {
        const currentBlock = fileData.slice(
          blockSize * i,
          Math.min(blockSize * (i + 1), fileData.length),
        );
        wstream.write(currentBlock);
      }
      wstream.end();
    });
    wstream.on('error'(err) => {
      reject(err);
    });
    wstream.on('finish'() => {
      resolve(true);
    });
  });
}


exports.genWord = async (event) => {
  let officegen = require('officegen');
  let fs = require('fs');
  let docx = officegen('docx');

  //ini
  delDir('/tmp')
  var item = event.item
  var filePath = 'exportVoluntaryData'
  var fileName = "21zyzm" + Date.parse(new Date()) + '.docx'
  var varpath = filePath + '/' + fileName

//=========以下建构文档内容==========

  //get QRcode
  const result = await cloud.openapi.wxacode.getUnlimited({
    page: 'pages/check/check',
    scene: item.id,
    width: 140,
  })
  const QRcode = result.buffer
  await saveFile('/tmp/qr.jpg', QRcode); // 这里的fileData是Buffer类型

  timeBottom = time.getFullYear() + '年' + (time.getMonth() + 1) + '月' + time.getDate() + '日'
  for (var i in item.volunteerInfo) {
    let pObj = docx.createP({
      align: 'center'
    })
    pObj = docx.createP({
      align: 'center'
    })
    pObj.addText('志愿服务时间证明', {
      bold: true,
      font_face: 'KaiTi',
      font_size: 19,
      color: '595959'
    });
    pObj = docx.createP()
    pObj = docx.createP({
      align: 'justify'
    })
    pObj.options.indentFirstLine = 440;
    if (item.volunteerInfo[i].academy && item.volunteerInfo[i].major && item.volunteerInfo[i].grade) {
      var txt = item.volunteerInfo[i].academy + ' ' + item.volunteerInfo[i].major + '专业 ' + item.volunteerInfo[i].grade + ' ' + item.volunteerInfo[i].name + ' 同学(学号 ' + item.volunteerInfo[i].idnum + '),于 ' + date + '参加 ' + item.title + ' 工作,志愿服务时间达到 ' + item.hours + ' 小时。'


    } else {
      var txt = item.volunteerInfo[i].name + ' 同学(学号 ' + item.volunteerInfo[i].idnum + '),于 ' + date + '参加 ' + item.title + ' 工作,志愿服务时间达到 ' + item.hours + ' 小时。'
    }
    pObj.addText(txt, {
      font_face: 'FangSong',
      font_size: 15,
      color: '595959'
    });
    pObj = docx.createP()
    pObj.options.indentFirstLine = 440;
    pObj.addText('特此证明。', {
      font_face: 'FangSong',
      font_size: 15,
      color: '595959'
    });
    pObj = docx.createP()
    pObj.options.indentFirstLine = 440;
    pObj.addText('证明人:' + event.tea_info.name + '  ' + event.tea_info.phone, {
      font_face: 'FangSong',
      font_size: 15,
      color: '595959'
    });
    pObj = docx.createP()
    pObj = docx.createP()
    pObj.options.indentFirstLine = 440;
    pObj.addText('微信扫描下方小程序码,可核验此证明。核验信息与此证明一致时,此证明不加盖公章仍然有效;若不一致,则以加盖公章的证明为准。', {
      font_face: 'FangSong',
      font_size: 12,
      color: '595959',
      italic: true,
    });
    pObj = docx.createP()
    pObj.options.indentFirstLine = 440;
    pObj.addImage('/tmp/qr.jpg', {
      cx: 140,
      cy: 140
    });
    pObj = docx.createP()
    pObj = docx.createP()
    pObj = docx.createP({
      align: 'right'
    })
    pObj.addText('XXXXX', {
      font_face: 'FangSong',
      font_size: 15,
      color: '595959'
    });
    pObj = docx.createP({
      align: 'right'
    })
    pObj.addText(timeBottom, {
      font_face: 'FangSong',
      font_size: 15,
      color: '595959'
    });

    // Add a Footer:
    pObj = docx.createP()
    pObj = docx.createP()
    pObj = docx.createP()
    pObj.addText('XXXXX证明_' + item._id, {
      font_face: 'FangSong',
      font_size: 10,
      color: '808080'
    });
    pObj = docx.createP()
    pObj.addText(time.toString(), {
      font_face: 'FangSong',
      font_size: 10,
      color: '808080'
    });


    if (i != ((item.volunteerInfo).length - 1)) {
      docx.putPageBreak()
    }
  }

//=======================建构文档内容结束=========================


  // Let's generate the Word document into a file:
  let out = fs.createWriteStream('/tmp/' + fileName)

  return new Promise((resolve, reject) => {
    docx.generate(out);
    out.on('close'async function(){
      console.log("文件已被关闭,总共写入字节", out.bytesWritten)
      // console.log('写入的文件路径是'+ out.path);
      var fileBuf = await readDocx_fs(out.path);
      var upd = await cloud.uploadFile({
        cloudPath: varpath,
        fileContent: fileBuf,
      });
      console.log(docx)

      resolve({
        event,
        upd,
        size: Math.floor(100*out.bytesWritten/1024)/100 + "KB"
      })
    });
    out.on('error'(err) => {
      console.error(err);
      reject({
        errMsg: err
      })
    });
  })
}
最后一次编辑于  2021-10-15  
点赞 17
收藏
评论

10 个评论

  • 九思
    九思
    2022-01-08
    const stream = fs.createWriteStream(filePath);
    stream.on('close',()=>{
      //这样就可以判断文件写完了
    })
    


    2022-01-08
    赞同 1
    回复
  • 仄人
    仄人
    2022-04-10

    线上地址的图片怎么插入进去呢?


    2022-04-10
    赞同
    回复
  • 陈式坚
    陈式坚
    2021-12-02

    为什么要强调自己非科班?

    2021-12-02
    赞同
    回复 1
    • 无言
      无言
      2021-12-10
      因为觉得自己菜啊,难免有些差错。事实证明确实有问题。
      2021-12-10
      回复
  • 汤加
    汤加
    2021-08-06

    你好,我用模拟器本地调试你的代码,在你的代码这个地方报错?报错内容是:Uncaught Exception:  Error: ENOENT: no such file or directory, open 'D:\tmp\example.docx'

    请问这个是什么问题?/tmp文件夹不是云函数默认提供的临时存储路径吗?

    let out = fs.createWriteStream('/tmp/example.docx')
    
    2021-08-06
    赞同
    回复 1
    • 无言
      无言
      2021-10-15
      你本地调试报的错吧,本地调试的话你需要自己在对应位置建一个文件夹
      2021-10-15
      回复
  • L
    L
    2021-04-02

    期待作者更多分享。

    2021-04-02
    赞同
    回复
  • L
    L
    2021-04-02

    回来感谢作者的。

    主要参考了你的文章和https://my.oschina.net/anziguoer/blog/1942214的两篇文章,断断续续修改了几天。

    文档生成的问题,我是这样解决的,希望能帮到你。

    把officegen的所有部分都放在一个generate函数的new Promise(resolve,reject)=>{}) 中。

    图片下载的部分也是单独写一个这样的函数downImg。

    然后在主函数入口下,

    先await downImg()

    再await generate()

    最后调用cloud.uploadFile()上传生成到文档到云存储
    



    2021-04-02
    赞同
    回复 1
    • 无言
      无言
      2021-10-15
      感谢大哥分享!最近因为有BUG重新修改了一下这个函数,最近会更新一下
      2021-10-15
      回复
  • 心平气和
    心平气和
    2021-02-05

    太感谢啦!

    最近也在做word输出的功能,研究了几天,最终殊途同归了

    2021-02-05
    赞同
    回复
  • 袁述~
    袁述~
    2020-11-14

    文章写得那么好,怎么那么少人点赞。我来挺你~~~

    2020-11-14
    赞同
    回复
  • 箫
    2020-08-14

    请问楼主,我把officegen这个项目整个下载保存到桌面了,然后按照您的步骤,进行到npm这时发生这个错误,请问这个该怎么解决呢?

    2020-08-14
    赞同
    回复 1
    • 箫
      2020-08-14
      哦哦我知道了,没有管理员权限打开cmd,谢谢楼主分享,辛苦了
      2020-08-14
      回复
  • 走出五千年
    走出五千年
    2020-02-21

    官方文档的搬运工?这还原创?

    2020-02-21
    赞同
    回复 4
    • 无言
      无言
      2020-02-21
      哈哈哈哈哈,有意思。
      2020-02-21
      回复
    • 走出五千年
      走出五千年
      2020-02-21回复无言
      呵呵哒
      2020-02-21
      1
      回复
    • 金柯
      金柯
      2020-07-29
      ???
      2020-07-29
      回复
    • 宋佳林|软件开发
      宋佳林|软件开发
      2021-12-16
      这位兄台反向夸人的技术实属🐂
      2021-12-16
      回复
登录 后发表内容