feat: 将word转pdf (#1380)

Co-authored-by: bai.zixv <bai.zixv@foxmail.com>
Co-authored-by: sealday <sealday@gmail.com>
Reviewed-on: daoyoucloud/tachybase#1380
Reviewed-by: sealday <zhanglin@daoyoucloud.com>
Co-authored-by: yoona <1486343814@qq.com>
Co-committed-by: yoona <1486343814@qq.com>
This commit is contained in:
yoona 2024-07-25 15:50:35 +08:00 committed by sealday
parent 730c7aef5b
commit 932048316c
6 changed files with 534 additions and 226 deletions

View File

@ -3,10 +3,20 @@
"version": "0.21.77", "version": "0.21.77",
"main": "dist/server/index.js", "main": "dist/server/index.js",
"dependencies": { "dependencies": {
"@bull-board/api": "^5.21.1",
"@bull-board/koa": "^5.21.1",
"bullmq": "^5.10.3",
"docxtemplater": "^3.48.0", "docxtemplater": "^3.48.0",
"koa-bodyparser": "^4.4.1",
"koa-router": "^10.1.1",
"pizzip": "^3.1.7", "pizzip": "^3.1.7",
"ts-node": "9.1.1",
"typescript": "^5.5.4",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"devDependencies": {
"@types/koa-router": "^7.4.8"
},
"peerDependencies": { "peerDependencies": {
"@tachybase/client": "workspace:*", "@tachybase/client": "workspace:*",
"@tachybase/server": "workspace:*", "@tachybase/server": "workspace:*",

View File

@ -1,21 +1,20 @@
import fs from 'fs'; import fs from 'fs'; //导入Node.js的文件系统模块用于文件操作
import path from 'path'; import path from 'path'; //导入Node.js的路径模块用于处理文件路径
import { Readable } from 'stream'; import { Readable } from 'stream'; // 从'stream'模块导入Readable类用于创建可读流
import { Context } from '@tachybase/actions'; import { Context } from '@tachybase/actions'; //一个自定义库,用于处理请求上下文
import { Model } from '@tachybase/database'; import { Model } from '@tachybase/database'; //用于数据库操作的模型
import Docxtemplater from 'docxtemplater'; //用于处理word文版模板
import Docxtemplater from 'docxtemplater';
// 导入 Docxtemplater 的 InspectModule 类 // 导入 Docxtemplater 的 InspectModule 类
import InspectModule from 'docxtemplater/js/inspect-module'; import InspectModule from 'docxtemplater/js/inspect-module'; //Docxtemplater使用它来处理Word文档。
import PizZip from 'pizzip'; import PizZip from 'pizzip'; //导入PizZip库用于处理ZIP文件Docxtemplater使用它来处理word文档
export const generate = async (ctx: Context) => { export const generate = async (ctx: Context) => {
const id = ctx.action.params.id; const id = ctx.action.params.id;
const repo = ctx.db.getRepository('templateManage'); const repo = ctx.db.getRepository('templateManage');
const template: Model = await repo.findOne({ const template: Model = await repo.findOne({
filter: { filter: { id },
id,
},
appends: ['template'], appends: ['template'],
}); });
@ -24,17 +23,19 @@ export const generate = async (ctx: Context) => {
const data = rawTemplate.testData; const data = rawTemplate.testData;
try { try {
// 生成定制的 docx 文件
const buffer = generateDocxFromTemplate(templatePath, data); const buffer = generateDocxFromTemplate(templatePath, data);
ctx.withoutDataWrapping = true; ctx.withoutDataWrapping = true;
ctx.body = buffer; ctx.body = buffer;
return buffer;
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);
ctx.status = 500; ctx.status = 500;
ctx.body = 'Error generating files: ' + error.message; ctx.body = 'Error generating files: ' + error.message;
return { filePath: '' }; // 返回空路径以防止进一步的错误
} }
}; };
//用于获取文档中的所有占位符标签
export const getTags = async (ctx: Context) => { export const getTags = async (ctx: Context) => {
const id = ctx.action.params.id; const id = ctx.action.params.id;
const repo = ctx.db.getRepository('templateManage'); const repo = ctx.db.getRepository('templateManage');
@ -48,13 +49,14 @@ export const getTags = async (ctx: Context) => {
const templatePath = path.join(process.env.PWD, rawTemplate.template[0].get().url); const templatePath = path.join(process.env.PWD, rawTemplate.template[0].get().url);
try { try {
const content = fs.readFileSync(templatePath, 'binary'); const content = fs.readFileSync(templatePath, 'binary'); //同步读取模板文件的内容
const zip = new PizZip(content); const zip = new PizZip(content); //用读取的内容创建一个PizZip对象
const iModule = new InspectModule(); const iModule = new InspectModule(); //创建一个InspectModule实例
const doc = new Docxtemplater(zip, { modules: [iModule] }); const doc = new Docxtemplater(zip, { modules: [iModule] }); //使用PizZip对象和InspectModule创建一个Docxtemplater实例
// 获取所有占位符 // 获取所有占位符
const allTags = iModule.getAllTags(); const allTags = iModule.getAllTags(); //InspectModule获取文档中的所有标签
console.log('All tags in the document:', allTags);
// 将 tags 转换为 JSON 格式的字符串 // 将 tags 转换为 JSON 格式的字符串
const tagsJson = JSON.stringify(allTags, null, 2); const tagsJson = JSON.stringify(allTags, null, 2);
@ -78,15 +80,17 @@ export const getTags = async (ctx: Context) => {
} }
}; };
//用于生成word文档的二进制数据
function generateDocxFromTemplate(templatePath, data): Buffer { function generateDocxFromTemplate(templatePath, data): Buffer {
try { try {
const content = fs.readFileSync(templatePath, 'binary'); const content = fs.readFileSync(templatePath, 'binary');
const zip = new PizZip(content); const zip = new PizZip(content);
const doc = new Docxtemplater(zip); const doc = new Docxtemplater(zip); //Docxtemplater 是一个用于处理DOCX模板并填充数据的库。
doc.setData(data); doc.setData(data);
doc.render(); doc.render(); //渲染模板,将数据填充到模板的占位符中
//调用 getZip 方法获取处理后的ZIP对象然后调用 generate 方法生成DOCX文件的二进制数据。{ type: 'nodebuffer' } 选项指定输出类型为Node.js的Buffer。buffer是一个缓存区用于存取二进制数据。
return doc.getZip().generate({ type: 'nodebuffer' }); return doc.getZip().generate({ type: 'nodebuffer' });
} catch (error) { } catch (error) {
console.error('Error generating DOCX from template:', error); console.error('Error generating DOCX from template:', error);

View File

@ -0,0 +1,63 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { Context } from '@tachybase/actions';
import { Queue } from 'bullmq';
import { generate } from './printTemplates';
// 用于将 Buffer 保存为临时文件并返回文件路径
function saveBufferToTempFile(buffer: Buffer): string {
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, `document-${Date.now()}.docx`);
fs.writeFileSync(tempFilePath, buffer);
return tempFilePath;
}
// 创建队列
const redisOptions = {
port: Number(process.env.REDIS_PORT || 6379),
host: process.env.REDIS_HOST || 'localhost',
password: process.env.REDIS_PASSWORD || '',
// 移除 tls 属性
};
const pdfConversionQueue = new Queue('default', { connection: redisOptions });
export const addConversionJob = async (ctx: Context) => {
try {
if (!ctx.action || !ctx.action.params) {
throw new Error('Missing action or action params in context');
}
// 生成文件路径并添加到队列
const result = await generate(ctx);
// 检查 result 类型
let filePath: string;
if (Buffer.isBuffer(result)) {
filePath = saveBufferToTempFile(result);
} else if (result && typeof result === 'object' && 'filePath' in result) {
filePath = (result as { filePath: string }).filePath;
} else {
throw new Error('Invalid result from generate function');
}
console.log('Generated DOCX file path:', filePath);
const outputDir = '/root/tachybase/storage/uploads';
// 添加作业到队列
const job = await pdfConversionQueue.add('convert', { wordFilePath: filePath, outputDir });
// 返回作业 ID 和 PDF 文件路径
ctx.body = { jobId: job.id };
} catch (error) {
console.error('Error adding conversion job:', error);
ctx.status = 500;
ctx.body = 'Error adding conversion job: ' + error.message;
}
};
export default addConversionJob;

View File

@ -1,54 +0,0 @@
import fs from 'fs';
import PizZip from 'pizzip';
import { parseStringPromise } from 'xml2js';
export async function getTemplateParams(templatePath) {
try {
const content = fs.readFileSync(templatePath, 'binary');
const zip = new PizZip(content);
const getXmlContent = (fileName) => {
const file = zip.file(fileName);
return file ? file.asText() : '';
};
const docXml = getXmlContent('word/document.xml');
const parseXml = async (xml) => {
try {
const result = await parseStringPromise(xml, { explicitArray: false, ignoreAttrs: true });
const body = result['w:document']['w:body'];
return Array.isArray(body['w:p']) ? body['w:p'] : [body['w:p']];
} catch (error) {
console.error('Error parsing XML:', error);
throw error;
}
};
const xmlContent = await parseXml(docXml);
const extractTags = (nodes) => {
const tags = new Set();
nodes.forEach((node) => {
if (node['w:r'] && Array.isArray(node['w:r'])) {
node['w:r'].forEach((r) => {
if (r['w:t'] && r['w:t']['#']) {
const matches = r['w:t']['#'].match(/\{[\w-]+\.}/g);
if (matches) {
matches.forEach((tag) => tags.add(tag));
}
}
});
}
});
return tags;
};
const tags = extractTags(xmlContent);
return Array.from(tags); // Ensure the return value is an array
} catch (error) {
console.error('Error in getTemplateParams:', error);
throw error;
}
}

View File

@ -1,6 +1,53 @@
import { exec } from 'child_process';
import path from 'path';
import util from 'util';
import { Plugin } from '@tachybase/server'; import { Plugin } from '@tachybase/server';
import { Worker } from 'bullmq';
import { generate, getTags } from './actions/printTemplates'; import { generate, getTags } from './actions/printTemplates';
import { addConversionJob } from './actions/producer';
const execPromise = util.promisify(exec);
const redisOptions = {
port: Number(process.env.REDIS_PORT || 6379),
host: process.env.REDIS_HOST || 'localhost',
password: process.env.REDIS_PASSWORD || '',
};
async function convertDocxToPdf(wordFilePath: string, outputDir: string, job: any): Promise<string> {
try {
const fileName = path.basename(wordFilePath, '.docx') + '.pdf';
const pdfFilePath = path.join(outputDir, fileName);
console.log(`Converting ${wordFilePath} to PDF at ${pdfFilePath}`);
const command = `libreoffice --headless --convert-to pdf --outdir "${outputDir}" "${wordFilePath}"`;
console.log('commandcommandcommand:', command);
// 更新进度到 20%:转换开始
job.updateProgress(20);
const { stdout, stderr } = await execPromise(command);
console.log('stdout', stdout);
console.log('stderr', stderr);
// 更新进度到 80%:转换进行中
job.updateProgress(80);
console.log(`PDF created successfully: ${pdfFilePath}`);
// 更新进度到 100%:转换完成
job.updateProgress(100);
return pdfFilePath;
} catch (error) {
console.error('Error during conversion:', error);
// 更新进度到 0% 或其他表示失败的值
job.updateProgress(0);
throw error;
}
}
export class PluginPrintTemplateServer extends Plugin { export class PluginPrintTemplateServer extends Plugin {
async afterAdd() {} async afterAdd() {}
@ -8,24 +55,47 @@ export class PluginPrintTemplateServer extends Plugin {
async beforeLoad() {} async beforeLoad() {}
async load() { async load() {
// localhost:3000/api/printTemplates:generate
this.app.resourcer.define({ this.app.resourcer.define({
name: 'printTemplates', name: 'printTemplates',
actions: { actions: {
generate, generate,
getTags, getTags,
addConversionJob,
}, },
}); });
this.app.acl.allow('printTemplates', '*', 'public'); this.app.acl.allow('printTemplates', '*', 'public');
this.app.use(require('koa-bodyparser')());
const worker = new Worker(
process.env.MSG_QUEUE_NAME || 'default',
async (job) => {
if (!job.data.wordFilePath || !job.data.outputDir) {
console.error('Invalid job data:', job.data);
throw new Error('Missing required job data properties');
}
const { wordFilePath, outputDir } = job.data;
// 自动更新进度
const pdfFilePath = await convertDocxToPdf(wordFilePath, outputDir, job);
// 返回 PDF 文件路径
return { pdfFilePath };
},
{ connection: redisOptions },
);
worker.on('completed', (job, returnValue) => {
console.log(`Job ${job.id} completed with result: ${JSON.stringify(returnValue)}`);
});
worker.on('failed', (job, err) => {
console.error(`Job ${job.id} failed with error: ${err.message}`);
});
} }
async install() {} async beforeDestroy() {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
} }
export default PluginPrintTemplateServer; export default PluginPrintTemplateServer;

File diff suppressed because it is too large Load Diff