379 lines
10 KiB
TypeScript
379 lines
10 KiB
TypeScript
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<Buffer> {
|
||
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<void> {
|
||
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<Buffer> {
|
||
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<ReturnType<PDFDocument['embedPng']>>,
|
||
): Promise<boolean> {
|
||
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<ReturnType<PDFDocument['embedPng']>>,
|
||
): 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<Buffer> {
|
||
// 先生成填充数据的 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);
|
||
}
|
||
}
|