import { Injectable, Logger } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; import { PDFDocument, PDFFont, rgb } from 'pdf-lib'; // eslint-disable-next-line @typescript-eslint/no-var-requires const fontkit = require('@pdf-lib/fontkit'); /** * 表单字段名称常量 * 在 PDF 模板中创建这些命名的文本字段 */ const FORM_FIELDS = { CONTRACT_NO: 'contractNo', USER_NAME: 'userName', USER_ID_CARD: 'userIdCard', USER_PHONE: 'userPhone', TREE_COUNT: 'treeCount', SIGN_YEAR: 'signYear', SIGN_MONTH: 'signMonth', SIGN_DAY: 'signDay', SIGNATURE: 'signature', // 签名图片按钮字段 } as const; /** * 合同 PDF 生成数据 */ export interface ContractPdfData { contractNo: string; // 合同编号: accountSequence-yyyyMMddHHmm userRealName: string; userIdCard: string; userPhone: string; treeCount: number; signingDate: string; // YYYY-MM-DD 格式 } /** * 签名嵌入数据 */ export interface SignatureData { signatureImagePng: Buffer; // PNG 格式的签名图片 } /** * PDF 生成服务 * * 使用 pdf-lib 操作 PDF: * 1. 优先使用 AcroForm 表单字段填充(更可靠) * 2. 如果模板没有表单字段,回退到坐标定位方式 * 3. 嵌入用户签名图片 * 4. 输出最终 PDF */ @Injectable() export class PdfGeneratorService { private readonly logger = new Logger(PdfGeneratorService.name); private readonly templatePath: string; private readonly fontPath: string; constructor() { this.templatePath = path.join( __dirname, '../../../templates/contract-template.pdf', ); this.fontPath = path.join( __dirname, '../../../templates/fonts/STKAITI.ttf', ); } /** * 检查 PDF 是否包含表单字段 */ private hasFormFields(pdfDoc: PDFDocument): boolean { try { const form = pdfDoc.getForm(); const fields = form.getFields(); return fields.length > 0; } catch { return false; } } /** * 生成合同 PDF(填充用户信息,不含签名) * @param data 合同数据 * @returns PDF Buffer */ async generateContractPdf(data: ContractPdfData): Promise { this.logger.log(`Generating PDF for contract: ${data.contractNo}`); try { // 1. 加载 PDF 模板 const templateBytes = fs.readFileSync(this.templatePath); const pdfDoc = await PDFDocument.load(templateBytes); // 2. 注册 fontkit 以支持自定义字体 pdfDoc.registerFontkit(fontkit); // 3. 加载中文字体 const fontBytes = fs.readFileSync(this.fontPath); const customFont = await pdfDoc.embedFont(fontBytes); // 4. 选择填充方式:优先使用表单字段,否则使用坐标定位 if (this.hasFormFields(pdfDoc)) { this.logger.log('Using form fields mode'); await this.fillFormFields(pdfDoc, data, customFont); } else { this.logger.log('Using coordinate mode (no form fields found)'); this.fillByCoordinates(pdfDoc, data, customFont); } // 5. 保存 PDF const pdfBytes = await pdfDoc.save(); this.logger.log(`PDF generated successfully for contract: ${data.contractNo}`); return Buffer.from(pdfBytes); } catch (error) { this.logger.error( `Failed to generate PDF for contract ${data.contractNo}:`, error, ); throw new Error(`PDF generation failed: ${error.message}`); } } /** * 使用表单字段填充 PDF(推荐方式,更可靠) * 需要 PDF 模板中预先创建命名的文本字段 */ private async fillFormFields( pdfDoc: PDFDocument, data: ContractPdfData, font: PDFFont, ): Promise { const form = pdfDoc.getForm(); const [year, month, day] = data.signingDate.split('-'); // 填充各个字段 const fieldMappings: Array<{ name: string; value: string }> = [ { name: FORM_FIELDS.CONTRACT_NO, value: data.contractNo }, { name: FORM_FIELDS.USER_NAME, value: data.userRealName || '未认证' }, { name: FORM_FIELDS.USER_ID_CARD, value: this.maskIdCard(data.userIdCard) }, { name: FORM_FIELDS.USER_PHONE, value: this.maskPhone(data.userPhone) }, { name: FORM_FIELDS.TREE_COUNT, value: data.treeCount.toString() }, { name: FORM_FIELDS.SIGN_YEAR, value: year }, { name: FORM_FIELDS.SIGN_MONTH, value: month }, { name: FORM_FIELDS.SIGN_DAY, value: day }, ]; for (const { name, value } of fieldMappings) { try { const field = form.getTextField(name); field.setText(value); field.updateAppearances(font); } catch (error) { this.logger.warn(`Form field '${name}' not found, skipping`); } } // 扁平化表单(将字段转换为静态文本,不可再编辑) form.flatten(); } /** * 使用坐标定位填充 PDF(后备方式) * 坐标需要根据实际 PDF 布局调整 */ private fillByCoordinates( pdfDoc: PDFDocument, data: ContractPdfData, customFont: PDFFont, ): void { const pages = pdfDoc.getPages(); const page1 = pages[0]; // 第1页:协议编号 const page2 = pages[1]; // 第2页:乙方信息 const page3 = pages[2]; // 第3页:种植期限、认种数量 const page6 = pages[5]; // 第6页:签名区域 const fontSize = 12; const textColor = rgb(0, 0, 0); // 填充第1页 - 协议编号(使用合同编号) page1.drawText(data.contractNo, { x: 390, y: 95, size: fontSize, font: customFont, color: textColor, }); // 填充第2页 - 乙方信息 page2.drawText(data.userRealName || '未认证', { x: 155, y: 583, size: fontSize, font: customFont, color: textColor, }); page2.drawText(this.maskIdCard(data.userIdCard), { x: 130, y: 557, size: fontSize, font: customFont, color: textColor, }); page2.drawText(this.maskPhone(data.userPhone), { x: 130, y: 531, size: fontSize, font: customFont, color: textColor, }); // 填充第3页 - 认种数量 page3.drawText(data.treeCount.toString(), { x: 138, y: 477, size: fontSize, font: customFont, color: textColor, }); // 填充第6页 - 签约日期 const [year, month, day] = data.signingDate.split('-'); page6.drawText(year, { x: 290, y: 103, size: fontSize, font: customFont, color: textColor, }); page6.drawText(month, { x: 345, y: 103, size: fontSize, font: customFont, color: textColor, }); page6.drawText(day, { x: 385, y: 103, size: fontSize, font: customFont, color: textColor, }); } /** * 在已生成的 PDF 上嵌入签名 * @param pdfBuffer 原始 PDF Buffer * @param signature 签名数据 * @returns 带签名的 PDF Buffer */ async embedSignature( pdfBuffer: Buffer, signature: SignatureData, ): Promise { this.logger.log('Embedding signature into PDF'); try { const pdfDoc = await PDFDocument.load(pdfBuffer); // 嵌入签名图片 const signatureImage = await pdfDoc.embedPng(signature.signatureImagePng); // 尝试使用表单字段放置签名 if (await this.tryEmbedSignatureByFormField(pdfDoc, signatureImage)) { this.logger.log('Signature embedded using form field'); } else { // 回退到坐标方式 this.logger.log('Signature embedded using coordinates (no form field found)'); this.embedSignatureByCoordinates(pdfDoc, signatureImage); } const pdfBytes = await pdfDoc.save(); this.logger.log('Signature embedded successfully'); return Buffer.from(pdfBytes); } catch (error) { this.logger.error('Failed to embed signature:', error); throw new Error(`Signature embedding failed: ${error.message}`); } } /** * 使用表单字段放置签名图片 * @returns 是否成功使用表单字段 */ private async tryEmbedSignatureByFormField( pdfDoc: PDFDocument, signatureImage: Awaited>, ): Promise { try { const form = pdfDoc.getForm(); const signatureButton = form.getButton(FORM_FIELDS.SIGNATURE); signatureButton.setImage(signatureImage); form.flatten(); return true; } catch { return false; } } /** * 使用坐标方式放置签名图片 */ private embedSignatureByCoordinates( pdfDoc: PDFDocument, signatureImage: Awaited>, ): void { const pages = pdfDoc.getPages(); const page6 = pages[5]; // 第6页:签名区域 // 计算签名图片的尺寸(保持宽高比,最大宽度150,最大高度60) const maxWidth = 150; const maxHeight = 60; const { width, height } = signatureImage.scale(1); let scaledWidth = width; let scaledHeight = height; if (width > maxWidth) { scaledWidth = maxWidth; scaledHeight = (height * maxWidth) / width; } if (scaledHeight > maxHeight) { scaledHeight = maxHeight; scaledWidth = (width * maxHeight) / height; } // 在"乙方(签字/盖章):"下方绘制签名 page6.drawImage(signatureImage, { x: 350, y: 180, width: scaledWidth, height: scaledHeight, }); } /** * 生成带签名的完整合同 PDF * @param data 合同数据 * @param signature 签名数据(可选,如果没有签名则只生成填充数据的 PDF) * @returns PDF Buffer */ async generateSignedContractPdf( data: ContractPdfData, signature?: SignatureData, ): Promise { // 先生成填充数据的 PDF let pdfBuffer = await this.generateContractPdf(data); // 如果有签名,嵌入签名 if (signature) { pdfBuffer = await this.embedSignature(pdfBuffer, signature); } return pdfBuffer; } /** * 脱敏身份证号 */ private maskIdCard(idCard: string | undefined): string { if (!idCard) return '****'; if (idCard.length < 6) return '****'; return idCard.slice(0, 6) + '********' + idCard.slice(-4); } /** * 脱敏手机号 */ private maskPhone(phone: string | undefined): string { if (!phone) return '****'; if (phone.length < 7) return '****'; return phone.slice(0, 3) + '****' + phone.slice(-4); } }