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",
|
"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:*",
|
||||||
|
@ -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);
|
||||||
|
@ -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 { 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;
|
||||||
|
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