rwadurian/backend/services/planting-service/src/infrastructure/pdf/pdf-generator.service.ts

379 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}