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