概述
在各种商业和政务类的应用系统当中,基于数据生成Office文档是一个非常常见的技术需求。原有的技术方案,可能包括直接使用代码结合Office对象类库来生成,但这样的技术方案开发工作量比较大,而且很容易出现排版的问题。
本文笔者经过一段时间的摸索研究,实验和使用了一种基于模板文档,使用数据填充来生成结果文档的技术方案,觉得开发和维护比较简单,模块化比较好,可扩展性强,可跨平台操作,甚至可以作为一种服务来提供。
方案概述
本技术方案主要应用的基础技术包括:
- bun 应用执行环境(当然也可以用nodejs)
- Docxtemplater npm(下简称DT), 作为主要的文档模板技术
- docxtemplater-image-module-free,图像处理模块
- pizzip(npm), office压缩算法库
- angular-expressions(npm), 用于增强标签表达式
- image-size(npm),用于计算图片尺寸
- liberoffice,非必要,用于pdf转换
下面分别就选型和其在方案中的作用做简单说明
Bun
其实也可以用nodejs,笔者使用bun是因为它更简单易用一点,依赖比较少,应用迁移和扩展比较方便。而且使用方式和nodejs基本没有什么差别。
bun还提供了比较方便的文件操作功能,还有它还提供了很多集成的特性,大大减少了外部程序和工具依赖,这个我们会在后续的代码中可以看到。
Docxtemplater
本技术方案的主体,就是Doctemplater这个程序库。当然,还有一些相关配套的模块,来完善相关的功能。
关于Docxtemplater
DT的官方网站在(下图): docxtemplater.com/

根据其官网的介绍,DT是一个强大的工具,它可以帮助你使用JavaScript结合标签(tags)语法,在程序中生成docx、pptx和xlsx文件。
Generate docx, pptx or xlsx from inside your
application with {tags} using Javascript
基本原理
笔者理解的DT基本原理和工作流程如下(以word文件为例)。
首先使用标准的Word软件,编辑和定义一个"模板文件"。这个文件的格式和框架,是按照业务需求定义的。但这时还没有具体的数据,而是使用一套"标签"(数据项)来放在将来有数据填充的位置。
DT程序运行时,先加载数据,然后加载这个模板文件,识别其中的标签,发现匹配的数据项目,就使用数据的内容来替换这个标签,从而完成数据的填充工作,然后将填充好数据的模板文件输出成为结果文件。这样做的好处是,当打开结果文件时,它是符合Word的原始格式定义的,就是一个标准的Word文件,它的布局如换行换页,都是Word的格式,这些内容都会自动处理,不需要编程。使用方式,就和手动录入的一样。
虽然简单的原理就是这样,但在实际运行中,需要考虑非常多的场景和问题,比如:
- 自动布局,因为布局定义是在模板中定义好的,所以内容应当可以自动布局
- 自动换页的问题,使用word格式应该可以自动处理,并且可以支持模板中定义的换页符
- 简单的内容显示控制,如可以使用条件控制填充内容,可以循环填充内容等等
- 多行数据表格的问题,如果要容纳的是一个不定长的数据数组,应当有机制在填写表格时也能够动态处理
- 动态标题和列表,可以通过数据循环,实现动态的标题列表
- 图片文件,可以从外部加载并填充图片
- 页眉页脚,在模板中定义,填充后自动处理
如果能够实现上面的应用场景和方式,那么这个系统应该能够满足绝大多数的业务需求了。不足的地方,也可以通过修改模板布局、合理定义数据结构来优化和规避过于复杂和容易出问题的格式定义。
显然,DT应用的核心,就是这套标签语法。
标签语法
DT提供了比较强大和丰富的标签语法,来满足业务系统中各种各样的需求。但实际上,我们日常使用的比较常用的,就是几种基本的方式,这里简单列举和探讨一些,并且就其中比较重要的部分进行展开说明,如果读者还有更深入的要求,可以参考其官方技术文档。
- Placeholder 占位符
这是最基本的形式,就是使用大括号包围数据对象属性:
{first_name} {last_name}
非常好理解,就是它将使用数据对象中对应属性的内容,来填充当前的位置。
- Condition 条件标签
基本形式为(注意所使用的 #标识 ): {#condition} and {/condition};
这里在数据中,条件属性的类型,应该是true/false。然后控制是否处理所有条件标签包围的内容。
条件标签还有一种形式是反向条件: {^your_inverted_section} {/your_inverted_section}
- Loop 循环标签
基本形式为: {#loop} and {/loop}
虽然感觉看起来简单,但笔者在应用中发现,DT支持好几种形式的循环方式,所以后面有更详细的讨论。
- Image 图像标签
基本形式为: {%image}
图像标签的使用,其实并没有像占位符那么简单,还要配套相关的模块和配置使用。后面会有详细的探讨。
- 循环表格
在很多业务系统的文档中,会大量使用表格。如果数据是循环的,也希望配套的表格也是动态循环的。DT可以很好的支持这种应用需求。在后面会有详细探讨。
- 嵌套对象标签
DT的基础版本,不支持嵌套的对象,要实现这个特性,需要所谓的Anglar Parser。后面详细讨论。
- Lamda
DT的标签,支持Lamda表达式,就是可以在配置信息中,自定义数据处理的方式,这给程序应用带来了极大的灵活性。后面也有简单的展开说明
循环标签
DT提供的循环标签是比较完善的,它不仅仅能够对数组进行循环处理,还可以支持简单数组和对象属性的循环。
- 对象数组, 这是对常用的情况
js
// 标签
{#products}
{name}, {price} €
{/products}
// 数据
"products": [
{ "name": "Windows", "price": 100 },
{ "name": "Mac OSX", "price": 200 },
{ "name": "Ubuntu", "price": 0 }
]
// 输出
Windows, 100 €
Mac OSX, 200 €
Ubuntu, 0€
- 简单数组, 数组成员即内容
js
// 标签
{#products}
{.}
{/products}
// 数据
"products": ["Windows", "Mac OSX", "Ubuntu"]
// 输出
Windows
Mac OSX
Ubuntu
- 对象, 可以循环指定的属性
js
{#people}
{name}
{age}
{occupation}
{/people}
// 数据
"people": {
"name": "Alice",
"age": 30,
"occupation": "Engineer"
}
// 输出
Alice
30
Engineer
数组成员和引用
DT可以很好的支持数组成员的引用,这个在实际业务应用中是一个非常有用的特性,可以大大简化模板文档的维护,和相应数据对象的生成。
js
// 模板文档
数据一:{dvalue[0]}, 数据二:{dvalue[1]}
// 数据对象
{
dvalue: ["数据1的值","数据2的值"]
}
图像模块 imageModule
DT的商业模式也是比较有特点的。它的基础程序是开源免费的。但好多扩展特性和模块,却是收费的,而且还不算便宜。其中,就包括了一个非常重要和基础的模块: image。(这估计也是它的主要盈利点, :))

可以看到,这个体系还是比较丰富和完善的。当然我们的重点是image模块。可能是考虑到这个特性过于基础和实用,网络上有人开发了一个free的版本。笔者尝试了一下,应该基础应用是没有问题的。使用的方式,也和官方模块类似,这里简单说明一下。
- docxtemplater-image-module-free
这是一个免费版本的配套的npm(使用 bun add 命令安装),据说有一定的版本配置限制,笔者在可执行的程序中,使用的是以下的组合:
bun 1.2.14
"docxtemplater": "^3.37.0",
"docxtemplater-image-module-free": "^1.1.1",
"angular-expressions": "^1.4.3",
- 标签
在模板文档中的标签,需要使用一个特殊的标签,属性名前面是 % ,如 {%photo},{%image_title}等等。
- 配置
要使用图像模块,首先要基于业务需求,进行相关的配置,这里是定义一个配置对象:
js
// base64 parser
const base64Regex =
/^(?:data:)?image\/(png|jpg|jpeg|svg|svg\+xml);base64,/;
// image module config
const imConfig ={
centered: true,
getImage(tagValue, tagName, meta) {
if (["photo"].includes(tagName)) {
return Buffer.from(tagValue.replace(base64Regex, ""), "base64");
} else if (tagValue.startsWith("http://") || tagValue.startsWith("https://")) {
return new Promise(function (resolve, reject) {
fetch(tagValue)
.then(response => {
if (!response.ok) throw new Error(`HTTP错误! 状态码: ${response.status}`);
return response.arrayBuffer(); // 返回解析为ArrayBuffer的Promise
})
.then(buffer => {
// console.log('获取的ArrayBuffer:', buffer);
// 后续处理:如解码音频、图像处理等
return resolve(buffer);
})
.catch(error => {
console.error('请求失败:', error);
return reject(error);
});
});
} else { // 读取本地文件
// console.log(tagName, tagValue);
return bunFile(resolve(PATH_IMAGE,tagValue)).arrayBuffer();
}
},
getSize(img,tagValue, tagName, meta) {
// it also is possible to return a size in centimeters, like this : return [ "2cm", "3cm" ];
if (tagName === "photo") return [80, 120];
// console.log(img);
// const sizeObj = imageSize(img); return [sizeObj.width, sizeObj.height];
return [200, 200];
},
};
这个配置对象的核心,其实就是两个自定义的方法。
首先就是图像内容获取的方式(getImage方法)。文中的处理方案,可以支持三种类型的图片数据,基本上覆盖了日常的应用场景。第一是base64编码的图片数据,可以直接内嵌在数据对象当中,当然这种情况只适合于图片比较小(如图标)的情况;第二就是本地文件,这应当是最合理的情况,但需要做好数据准备工作,在文档生成前,就将图片放在本地磁盘或者可以直接以文件系统形式访问的路径当中;第三就是网络图片文件,最常见的就是http协议的网络图片,就跟在html页面当中一样,这种情况有一个问题就是需要在输出的时候进行访问,可能需要修改访问资源的逻辑和方法,比如文中的fetch方法。我们可以看到,无论使用何种方法,只要最终可以返回一个二进制数据(arrayBuffer)就可以了。
其次是定义图像在模板文档中的大小(getSize方法,结果是数组形式,分别代表宽和高,单位是px)。这也是根据业务需求来确定,比如需要在特定的位置显示照片,这个照片就需要限定大小和比例。理论上所有的图像大小,都需要开发者根据标签来定义。奇怪的是,DT并没有提供默认的大小(如图像原始大小),这样就很不方便,这里笔者的处理方式是给了个默认值 200x200。
这里多提一句,getSize的第一个参数img,注入的是一个图片的arrarBuffer,要获取它的尺寸,需要调用某个图片内容解析程序,这里有一个库imageSize可以完成这个工作,但在笔者的程序中,不知道是什么缘故,它不能按照构想的情况工作。但原理上比较简单清楚,如果有需求,笔者可能需要寻找更好的解决方案。
- 图像模块实例创建和加载
基于配置信息和方法,可以创建相应的图像模块实例,用于后续模板实例的创建。创建完成,就可以在文档模板实例创建的时候进行加载了,这个过程的完整参考代码如下:
js
const ImageModule = require("docxtemplater-image-module-free");
const docTemplate = new Docxtemplater(new PizZip(tempBuffer), {
paragraphLoop: true,
linebreaks: true,
parser: expressionParser,
// with image module
modules: [ new ImageModule(imConfig)],
});
可以看到,文中使用了docxtemplater-image-module-free。创建实例本身非常简单,就是使用配置信息即可。而加载的过程,是通过在文档模板示例创建的时候,通过指定模块列表来进行的,其中就包括了新建的图像模块实例。
- 异步操作
细心的读者应该可以发现,笔者的测试代码中,通过网络获取图片内容的操作,是一个fetch调用,并且返回的是一个promise对象,不是直接的二进制数据。这里是采用了异步操作。异步操作的时候,需要在数据填充和渲染的时候,也使用配套的异步方法,这个我们在后面的实现中可以看到。
循环表格
以一个常见的报名表为例,它的输出结果可能需要是这样的:

在DT中,是这样实现的:
js
// 模板标签
Name Age Phone Number
{#users} {name} {age} {phone}{/}
// 数据
"users": [
{
"name": "John",
"age": 22,
"phone": "+33777777777"
},
{
"name": "Mary",
"age": 25,
"phone": "+33666666666"
}
]
可以看到,循环表格本质上就是一种循环标签的特殊应用。DT支持在表格定义中使用循环。
嵌套对象
其实Anglar Parse不仅仅支持嵌套对象,而且支持表达式标签,嵌套对象其实只是它最基本的用法。要使用这个特性,需要先安装对应的npm:
npm install --save angular-expressions
"angular-expressions": "^1.4.3",
按照其官方技术文档的说法,应该是在很大程度上实现了angular的标签语法,常见的标签和应用场景包括:
- {user.name}: 嵌套对象属性访问
- {price + tax}: 属性值计算
- {name | toUpperCase}: 值转换,如转换为大写
- {name = "John"}: 赋值
- {name === "John"}: 条件表达式
- {#$index == 0}First item !{/}: 内置逻辑属性
关于这个标签表达式体系,比较丰富和复杂,这里的应用比较简单,就不展开讨论了。
但是,这个特性,在DT中不是原生的,而是作为一个外部可配置项目提供的。需要在创建模板文档实例时,进行相关的配置:
js
const expressionParser = require("docxtemplater/expressions.js");
const doc = new Docxtemplater(zip, {
parser: expressionParser,
});
doc.render({
user: {
name: "John",
},
});
有了angular表达式,我们还可以将条件标签改进一下,实现起来更加直观方便:
js
{#users.length>1}
There are multiple users
{/}
{#users[0].name == "John"}
Hello {users[0].name}, welcome back
{/}
可以看到,只有有了angular表达式或者类似的技术,标签模板系统才算是比较完善。笔者强烈建议将其作为一个基础特性集成到DT当中。此外如果不能的话,笔者也建议开发者使用时作为一个基础配置。
Lamda
Lamda就是方法属性,这个属性被引用的时候,其实会执行一个方法,将结果返回使用,我们通过一个简单的示例就可以理解了:
js
// 数据
{
userGreeting: (scope) => "How is it going, " + scope.user + " ?",
users: [
{ name: "John", },
{ name: "Mary", },
],
}
// 标签
{#users}
{userGreeting}
{/}
// 输出
How is it going, John ?
How is it going, Mary ?
如示例所示,我们通常可以在一个循环中使用lamda,达成动态构造内容的效果。这种处理方式,和JS的回调函数使用非常类似,充分的利用了js中方法即对象的特性。
扩展特性
笔者在本文成文和查阅资料时感觉到,DT还是一个比较庞大完善的系统,相关的辅助系统和可定制性很强,这里没有篇幅深入探讨,也不是本文的重点,下面只是列举一个觉得比较重要的方面,希望能够增强读者对DT技术的了解,并且对应应用到实际的开发工作中有所帮助:
- DT支持RAW XML标签和内容(可能和office原生xml格式有关)
- 同样,DT也支持HTML风格和内容
- 同angular一样,DT支持用户自定义的解析程序
- DT的标签语法,是可以自定义的,比如{} 可以改成 [[ ]]
- DT可以执行在浏览器环境中
- DT也支持excel和powerpoint
在初步理解了Docxtemplater之后,我们可以回到本文的重点,就是如何应用其实现一个可以运行的文档生成程序。
实现流程和技术
基本构想
笔者对于此程序项目的基本构想如下:
- 这是一个标准的bun或者nodejs应用
- 程序的输入,包括一个文档模板,和模板数据
- 如果数据中,需要处理图片文件,使用一个单独的文件夹来保存准备要处理的图片文件
- 程序的输出,就是处理模板数据,填充和渲染文档模板,并且生成输出文档
- 程序运行时,可以从输入数据文件夹中,读取数据,并从模板文件夹中加载文件,处理后保存结果文件到输出文件夹
- 可以方便的配置模板文件和数据文件,以适应不同的业务需求
基于以上的程序设计构想,我们可以设计和实现如下的应用程序项目。
项目构成
本方案使用的测试项目构成非常简单,基本上就是一个简单的nodejs或者bun项目,项目文件夹中包括一个主执行文件,和一些辅助性的目录和文档。包括:
- package.json 项目文件,bun兼容
- index.ts 主执行文件,可以由bun或者nodejs调用启动
- input 输入数据文件夹,包括多个.json文件,作为数据源,如001.json
- output 输出结果文件夹
- template 模板文件夹
- template/stuArchive.doc 模板文件(可以多个)
- images文件夹,文件模式的图片文件存储文件夹
依赖安装
应用开发和执行前,需要安装一些外部依赖:
package.json
"dependencies": {
"angular-expressions": "^1.4.3",
"docxtemplater": "^3.37.0",
"docxtemplater-image-module-free": "^1.1.1",
"image-size": "^2.0.2",
"pizzip": "^3.2.0"
}
使用npm install和bun add都可以安装这些依赖。需要注意的问题是,由于使用的image-module-free的版本,需要配合特定的DT版本,所以不能安装最新的依赖版本,笔者这里列出的是一套可以运行的依赖程序的组合。
引用和配置
最后形成的主程序其实非常简单,而且是单一的程序文件,都在index.ts中。
程序代码中的引用和配置设置包括:
index.ts
const // Builtin file system utilities
{ resolve } = require("path"),
// Load our library that generates the document
Docxtemplater = require("docxtemplater"),
// Load PizZip library to load the docx/pptx/xlsx file in memory
PizZip = require("pizzip"),
// image module
{ imageSize } = require("image-size"),
ImageModule = require("docxtemplater-image-module-free"),
// agula parser
expressionParser = require("docxtemplater/expressions.js");
import { Glob, file as bunFile, write as bunWrite } from 'bun';
const
PATH_TEMPLATE = resolve(__dirname, "template/"), // Template file path
PATH_IMAGE = resolve(__dirname, "images/"), // Output file path
PATH_INPUT = resolve(__dirname, "input/"), // Output file path
PATH_OUTPUT = resolve(__dirname, "output/"), // Output file path
DOC_TEMPLATE = resolve(PATH_TEMPLATE, "stuArchive.docx"); // Template file name,
此处,指定了文档生成所需要的四个文件夹:输入数据,模板文件、输出目录和图片文件夹。基本的项目结构就是如此,我们先来看看在程序中,所使用模板文档和相关的数据。
模板和数据加载
程序的主要部分,笔者认为是两个步骤。首先是加载模板,然后从输入文件夹中,遍历准备要进行处理的数据;第二个部分就是使用当前数据和文档模板,生成对应的输出文件。
我们先来看第一个部分:
js
const start = async () => {
// Scans the current working directory and each of its sub-directories recursively
let jdata, outFile;
// Load the docx file as binary content and Unzip the content of the file
const tempBuffer = await bunFile(DOC_TEMPLATE).arrayBuffer();
const glob = new Glob("**/*.json");
for await (const f of glob.scan(PATH_INPUT))
try {
// info data
jdata = await bunFile(resolve(PATH_INPUT, f)).json(); // Bun 内置文件读取
outFile = resolve(PATH_OUTPUT, f.replaceAll('.json', '')+"-"+jdata.xm+".docx");
await renderFile(jdata, tempBuffer, outFile);
} catch (error) {
console.error(`解析失败: `, error);
}
}; start();
简单说明一下:
- 由于使用的模板都是一样的,我们这里只需要加载一次进行准备
- 使用bun的glob.scan方法,可以列表输入目录中,所有.json文档
- 使用bunFile方法读取文档内容,并转换成为json数据
- 同时基于json内容,设置好要输出的文件名称(位于输出路径)
- 调用模板渲染方法,给文档模板填充数据,并且生成输出文件(下一章节)
文档生成
使用数据,来填充对应的文档模板,在DT中的术语,叫做渲染(render)。这个过程笔者将其封装成为了一个renderFile方法,实现的参考代码如下:
js
// render file with template and data ,
const renderFile = async (data, tempBuffer, outFile)=>{
try {
// template and render
// Load the docx file as binary content and Unzip the content of the file
const docTemplate = new Docxtemplater(new PizZip(tempBuffer), {
paragraphLoop: true,
linebreaks: true,
parser: expressionParser,
// with image module
modules: [ new ImageModule(imConfig)],
});
// docTemplate.setData(data);
await docTemplate.renderAsync(data);
// output buffer to file
const buffer = docTemplate.getZip().generate({ type: "nodebuffer", compression: "DEFLATE", });
bunWrite(outFile, buffer); // Bun 内置文件写入
} catch (error) {
console.error(`解析失败: `, error);
}
}
简单说明一下:
- 输出参数包括数据、文档模板(已转换的buffer)和输出文件
- 基于文档模板buffer,创建一个DocxTemplater的实例
- 配置信息包括自定义的解析器(支持复杂对象标签)
- 配置信息中的模块列表,包括新创建的图像模块
- 使用renderAsync(data)方法,来执行模板的渲染
- 使用gernate方法,生成渲染输出的结果(buffer)
- 将buffer写入到输出文件中
这里有一些笔者觉得奇怪或者有必要说明的地方:
- 在笔者的实验中,DocxTemplater和ImageModule实例都是不能复用的,虽然其基础配置其实并没有更改,所以这里只能由原始模板buffer新生成模板实例,并且使用新创建的图形模块实例
- 模板的buffer也不能直接使用,而是需要先转换成为一个pizzip对象,笔者觉得奇怪的地方是如果这是一个必要的步骤,为什么不直接将其封装到构建方法当中
- 这个输出方式也比较奇怪,做了很多操作,但实际上结果就是一个buffer
- 这里不知道为什么,也不能使用setData方法,来设置数据,因为最终的render操作在逻辑上是可以和数据设置分离的
- 这里还需要注意,必须使用renderSync方法,来配合getImage中的同步加载内容的方法
总之,虽然这个DT处理文档的功能是比较强大的,但里面的坑还是比较多,对程序、模板和数据的准备都有很多要求,过程中很容易出问题。
模板文档
模板文件,就是普通的Word文档,根据要输出的结构和内容进行编辑,然后定义要替换的内容,使用标签的形式来进行表示。如下面的一个基本Word文件框架:

文件编辑完成后,将其保存在template文件夹中。就可以在程序中加载使用了。(此处是程序中使用的stuArchive.docx文件)
我们可以清楚的看到,这些动态的内容标签,显然是有一定的规范和规则的,这就是有DT提供的标签语法。关于这些标签和语法的具体使用,我们在后面的章节有更进一步的探讨。
数据准备
本测试项目中,为了开发和编辑方便,数据是以json文件的形式存储的。当然也可以非常方便的转换成为数据库存储数据,或者使用接口的方式来提供。
项目中有一个input文件夹,每一个数据,都是存储在一个json文件中,例如:
input/001.json

{
"xm": "颜安佑",
"xb": "男",
"mz": "汉族",
"sfzjh": "8201041200701251577",
"zzmm": "群众",
"csrq": "2004-12-16",
"byxx": "XX中学",
"bynj": "高2023级",
"lxfs": "13812345678",
"jtzz": "甘肃省兰州市城关区雁南街道五社区",
"sxzz": "思想政治的内容",
"yssy": "艺术素养的内容",
"img_1": "https://docxtemplater.com/puffin.png",
"photo" : "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QIJBywfp3IOswAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAkUlEQVQY052PMQqDQBREZ1f/d1kUm3SxkeAF/FdIjpOcw2vpKcRWCwsRPMFPsaIQSIoMr5pXDGNUFd9j8TOn7kRW71fvO5HTq6qqtnWtzh20IqE3YXtL0zyKwAROQLQ5l/c9gHjfKK6wMZjADE6s49Dver4/smEAc2CuqgwAYI5jU9NcxhHEy60sni986H9+vwG1yDHfK1jitgAAAABJRU5ErkJggg==",
"head": "head.jpg",
"tasks": [
{ "time": "2023-01-01", "task": "完成数学作业", "content": "很好", "summary": "总结1" },
{ "time": "2023-01-01", "task": "参加篮球训练", "content": "很好", "summary": "总结2" },
{ "time": "2023-01-01", "task": "阅读《三国演义》", "content": "很好", "summary": "总结3" },
{ "time": "2023-01-01", "task": "参加英语角活动", "content": "很好", "summary": "总结4" },
{ "time": "2023-01-01", "task": "准备物理实验报告", "content": "很好", "summary": "总结5" }
],
"hasStjk": true,
"stjk": { "content": "陶安佐,身体健康的内容"}
}
数据的内容可以使用任意文本编辑器来进行编写(当然也可以方便的由程序数据库或者接口查询后生成),只需要支持json规范和UTF8编码即可。每个数据文件就是一套数据,其实就是一个大型的json对象。数据文档的后缀名称统一为.json,命名不重要,但最好有一定的规则方便管理。程序执行时,会自动扫描输入文件夹中的json文件进行处理。
文档的实际内容,其实本身并不重要,主要由业务来确定,并且是和其预期使用的模板是配套的,所以,是需要先根据业务要求,设计出结果文档的结构,和需要配套填充的数据内容,然后形成需要提供的数据结构。
这个实例示例数据,展现了业务中经常用的一些数据的结构,层级关系和数据形式。而且为了配套文件模板,除了原始数据之外,还可能需要定义一些辅助性的数据,例如此处可选的"hasStjk",就是用来控制是否处理相关章节的。还有为了方便管理和生成,示例中还使用了嵌套的对象(stjk等)。
文档数据中,还包括了图片的使用。示例中,展现了三种图片内容的使用方式,包括内嵌base64数据,http链接和本地文件,都是可以支持的,这就给业务数据集成带来了很大的灵活性。
关于这些控制性的数据,嵌套对象,图形内容等方面使用等等,后面在文件标签章节中还会详细说明。
执行和输出结果
在所有依赖库,程序,配置信息,模板文档和数据文件都就绪之后,执行是非常简单的:
bun .
执行后,程序会在输出文件夹中,生成和输入文件对应的输出文件的doc文件,顺利的话,每个输入的json文件,都会有一个对应的docx文件(下图)。

在笔者的开发系统上,处理这么一个文档,大概需要2秒左右的时间,但CPU占用非常小,笔者分析,可能主要是其中需要从网络上获取和解析图片造成的。后续实际业务要用到的话,笔者会做一个更科学的性能和资源占用测试分析。
笔者觉得比较奇怪的是,DT虽然可以方便的生成DOC文件,却没有直接提供工具和方法,在doc文件的基础上,生成pdf文件。因为这也是一个非常常见和重要的应用场景。很多文档,作为一种发布方式,并不希望直接以doc文件的方式提供,而是希望使用pdf的形式提供,因为不希望也不需要目标用户能够修改其中的内容。
笔者找到的解决方案,是使用liberoffice进行docx格式到pdf格式的转换。这个转换虽然需要按照liberoffice软件(一次性的),但liberoffice却可以以"无头"的方式,在命令行环境以脚本方式执行,无需图形界面和GUI环境。也就是说,这个过程可以方便的脚本化和自动化。
笔者在debian系统上的操作过程如下:
js
# 安装(Debian/Ubuntu)
sudo apt install libreoffice
# 转换命令(无界面模式)
localhost% time libreoffice --headless --convert-to pdf *.docx
convert /home/yanjh/001-颜安佑.docx -> /home/yanjh/001-颜安佑.pdf using filter : writer_pdf_Export
Overwriting: /home/yanjh/001-颜安佑.pdf
convert /home/yanjh/003-陶安佐.docx -> /home/yanjh/003-陶安佐.pdf using filter : writer_pdf_Export
Overwriting: /home/yanjh/003-陶安佐.pdf
libreoffice --headless --convert-to pdf *.docx 3.31s user 0.37s system 104% cpu 3.510 total
localhost% ls -l
total 70224
-rw-rw-r-- 1 yanjh yanjh 94376 Jul 1 17:38 001-颜安佑.docx
-rw-rw-r-- 1 yanjh yanjh 83316 Jul 1 17:39 001-颜安佑.pdf
-rw-rw-r-- 1 yanjh yanjh 94404 Jul 1 17:38 003-陶安佐.docx
-rw-rw-r-- 1 yanjh yanjh 83182 Jul 1 17:39 003-陶安佐.pdf
以上可以看到,这个命令的执行非常简单,有几个选项:
- --headless: 指定libreoffice以无头方式执行
- --convert-to pdf: 指定转换为pdf文档
- 可以使用通配符指定多个输入文件
- 自动命名输出文件(也可以通过output指定)
- 转换性能不算很好,转换两个文件,需要3秒左右(当然笔者测试用主机性能也比较差,而且这些文档是带图片的)
主要问题
在成文过程中实验和评估中,笔者感觉到这一技术方案还是存在一些问题:
- 兼容性和可移植性
DocxTemplater的兼容性不是很好,笔者在适配和移植的时候,遇到了很多问题。比如和ImageModuleFree,还有Bun都有一些兼容的问题,要特别小心系统、软件版本的组合和匹配的问题。
例如,笔者在初始开发的时候,使用的bun版本,是从npm安装的, 执行环境是AMD64 Windows 11操作系统。调试和运行都没有什么问题。但当笔者想要将其移植到一个AMD64 Linux系统之上的时候,使用单独安装的bun, 完全相同的代码,就不能执行了。甚至笔者将原始npm安装的bun卸载后,单独安装bun,都无法执行。最后还是将代码修改成为nodejs兼容的版本,才能够在linux系统上运行。而且最令人无语的是,执行中抛出的错误,都是DT系统级别的基础错误,也很难看出头绪。
- 格式
在不同的平台上,doc生成的文档,显示的结果,好像在不同的平台上略有不同。比如,很多情况下,linux可能使用的字体和windows不同,或者有些字体文件不存在只能使用替代字体,就可能影响显示和输出。
- 性能
从数据转成doc文档的性能尚可,一般300K文档的转换时间在300ms左右。但由doc转成pdf文档的过程比较耗时,大约需要4到5秒这个级别,这个性能在大规模转换时,可能是一个问题。
扩展和服务
基于以上的实现,我们可以发现,可以轻易的将其封装起来,扩展成为一种服务体系。比如一个临时的大批量文档生成操作。可以在多台工作主机上部署文档生成应用,然后从中央数据系统上,获取数据,在本地生成文档后,提交到统一的存储位置。这种模式适合于需要在短时间内生成海量的数据化和格式化文档的情况。
还有一种应用模型,是将这个应用服务化。比如可以建立一个文档生成的Web应用,管理员可以上传维护一套基础模板;使用者可以基于模板,提供数据,调用生成文档的接口(同步异步都可以),然后下载生成的结果,这样可以将具体的业务系统,和文档生成应用解耦,可以使开发和集成更加简单方便。
小结
本文探讨了笔者在工作中遇到的一个需要大量生成带有数据文件生成的技术方案。这个过程中包括了技术选型,特别是讨论了Docxtemplater,包括其背景,工作原理,核心标签语法体系,图像模块,循环控制等等。然后基于此技术实现了一个可以运行的示例程序,包括程序架构,主要功能实现,辅助文档等等。