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:
parent
730c7aef5b
commit
932048316c
@ -3,10 +3,20 @@
|
||||
"version": "0.21.77",
|
||||
"main": "dist/server/index.js",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^5.21.1",
|
||||
"@bull-board/koa": "^5.21.1",
|
||||
"bullmq": "^5.10.3",
|
||||
"docxtemplater": "^3.48.0",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-router": "^10.1.1",
|
||||
"pizzip": "^3.1.7",
|
||||
"ts-node": "9.1.1",
|
||||
"typescript": "^5.5.4",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/koa-router": "^7.4.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tachybase/client": "workspace:*",
|
||||
"@tachybase/server": "workspace:*",
|
||||
|
@ -1,21 +1,20 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { Context } from '@tachybase/actions';
|
||||
import { Model } from '@tachybase/database';
|
||||
import fs from 'fs'; //导入Node.js的文件系统模块,用于文件操作
|
||||
import path from 'path'; //导入Node.js的路径模块,用于处理文件路径
|
||||
import { Readable } from 'stream'; // 从'stream'模块导入Readable类,用于创建可读流
|
||||
import { Context } from '@tachybase/actions'; //一个自定义库,用于处理请求上下文
|
||||
import { Model } from '@tachybase/database'; //用于数据库操作的模型
|
||||
|
||||
import Docxtemplater from 'docxtemplater'; //用于处理word文版模板
|
||||
|
||||
import Docxtemplater from 'docxtemplater';
|
||||
// 导入 Docxtemplater 的 InspectModule 类
|
||||
import InspectModule from 'docxtemplater/js/inspect-module';
|
||||
import PizZip from 'pizzip';
|
||||
import InspectModule from 'docxtemplater/js/inspect-module'; //Docxtemplater使用它来处理Word文档。
|
||||
import PizZip from 'pizzip'; //导入PizZip库,用于处理ZIP文件,Docxtemplater使用它来处理word文档
|
||||
|
||||
export const generate = async (ctx: Context) => {
|
||||
const id = ctx.action.params.id;
|
||||
const repo = ctx.db.getRepository('templateManage');
|
||||
const template: Model = await repo.findOne({
|
||||
filter: {
|
||||
id,
|
||||
},
|
||||
filter: { id },
|
||||
appends: ['template'],
|
||||
});
|
||||
|
||||
@ -24,17 +23,19 @@ export const generate = async (ctx: Context) => {
|
||||
const data = rawTemplate.testData;
|
||||
|
||||
try {
|
||||
// 生成定制的 docx 文件
|
||||
const buffer = generateDocxFromTemplate(templatePath, data);
|
||||
ctx.withoutDataWrapping = true;
|
||||
ctx.body = buffer;
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
ctx.status = 500;
|
||||
ctx.body = 'Error generating files: ' + error.message;
|
||||
return { filePath: '' }; // 返回空路径以防止进一步的错误
|
||||
}
|
||||
};
|
||||
|
||||
//用于获取文档中的所有占位符标签
|
||||
export const getTags = async (ctx: Context) => {
|
||||
const id = ctx.action.params.id;
|
||||
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);
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(templatePath, 'binary');
|
||||
const zip = new PizZip(content);
|
||||
const iModule = new InspectModule();
|
||||
const doc = new Docxtemplater(zip, { modules: [iModule] });
|
||||
const content = fs.readFileSync(templatePath, 'binary'); //同步读取模板文件的内容
|
||||
const zip = new PizZip(content); //用读取的内容创建一个PizZip对象
|
||||
const iModule = new InspectModule(); //创建一个InspectModule实例
|
||||
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 格式的字符串
|
||||
const tagsJson = JSON.stringify(allTags, null, 2);
|
||||
@ -78,15 +80,17 @@ export const getTags = async (ctx: Context) => {
|
||||
}
|
||||
};
|
||||
|
||||
//用于生成word文档的二进制数据
|
||||
function generateDocxFromTemplate(templatePath, data): Buffer {
|
||||
try {
|
||||
const content = fs.readFileSync(templatePath, 'binary');
|
||||
const zip = new PizZip(content);
|
||||
const doc = new Docxtemplater(zip);
|
||||
const doc = new Docxtemplater(zip); //Docxtemplater 是一个用于处理DOCX模板并填充数据的库。
|
||||
|
||||
doc.setData(data);
|
||||
doc.render();
|
||||
doc.render(); //渲染模板,将数据填充到模板的占位符中
|
||||
|
||||
//调用 getZip 方法获取处理后的ZIP对象,然后调用 generate 方法生成DOCX文件的二进制数据。{ type: 'nodebuffer' } 选项指定输出类型为Node.js的Buffer。buffer是一个缓存区,用于存取二进制数据。
|
||||
return doc.getZip().generate({ type: 'nodebuffer' });
|
||||
} catch (error) {
|
||||
console.error('Error generating DOCX from template:', error);
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,6 +1,53 @@
|
||||
import { exec } from 'child_process';
|
||||
import path from 'path';
|
||||
import util from 'util';
|
||||
import { Plugin } from '@tachybase/server';
|
||||
|
||||
import { Worker } from 'bullmq';
|
||||
|
||||
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 {
|
||||
async afterAdd() {}
|
||||
@ -8,24 +55,47 @@ export class PluginPrintTemplateServer extends Plugin {
|
||||
async beforeLoad() {}
|
||||
|
||||
async load() {
|
||||
// localhost:3000/api/printTemplates:generate
|
||||
this.app.resourcer.define({
|
||||
name: 'printTemplates',
|
||||
actions: {
|
||||
generate,
|
||||
getTags,
|
||||
addConversionJob,
|
||||
},
|
||||
});
|
||||
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 afterEnable() {}
|
||||
|
||||
async afterDisable() {}
|
||||
|
||||
async remove() {}
|
||||
async beforeDestroy() {}
|
||||
}
|
||||
|
||||
export default PluginPrintTemplateServer;
|
||||
|
505
pnpm-lock.yaml
505
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user