339 lines
9.4 KiB
TypeScript
339 lines
9.4 KiB
TypeScript
import { saveAs } from 'file-saver';
|
|
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
|
|
|
const separator = '\x1f';
|
|
|
|
export interface CustomerInfo {
|
|
firstName: string;
|
|
lastName: string;
|
|
postalAddress: string;
|
|
email: string;
|
|
}
|
|
|
|
export interface NotaryInfo {
|
|
name: string;
|
|
// Add more notary fields as needed
|
|
}
|
|
|
|
export interface Metadata {
|
|
fileName: string,
|
|
createdAt: Date,
|
|
isDeleted: boolean,
|
|
updatedAt: Date,
|
|
commitmentId: string,
|
|
documentUid: string;
|
|
documentType: string;
|
|
merkleProof: string;
|
|
}
|
|
|
|
export interface CertificateData {
|
|
customer: CustomerInfo;
|
|
notary: NotaryInfo;
|
|
folderUid: string;
|
|
documentHash: string;
|
|
metadata: Metadata;
|
|
}
|
|
|
|
export default class PdfService {
|
|
private static instance: PdfService;
|
|
|
|
public static getInstance(): PdfService {
|
|
if (!PdfService.instance) {
|
|
PdfService.instance = new PdfService();
|
|
}
|
|
return PdfService.instance;
|
|
}
|
|
|
|
/**
|
|
* Generate a certificate PDF for a document
|
|
* @param certificateData - Data needed for the certificate
|
|
* @returns Promise<Blob> - The generated PDF as a blob
|
|
*/
|
|
public async generateCertificate(certificateData: CertificateData): Promise<Blob> {
|
|
try {
|
|
const pdfDoc = await PDFDocument.create();
|
|
const page = pdfDoc.addPage([595, 842]); // A4 size
|
|
const { width, height } = page.getSize();
|
|
|
|
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
const helveticaBoldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
const helveticaObliqueFont = await pdfDoc.embedFont(StandardFonts.HelveticaOblique);
|
|
|
|
let y = height - 40; // Start 40pt from the top
|
|
const leftMargin = 20;
|
|
const rightSectionX = 320;
|
|
const lineSpacing = 16;
|
|
|
|
// Notary Information Section (Top Left)
|
|
page.drawText('Notaire Validateur:', {
|
|
x: leftMargin,
|
|
y: y,
|
|
size: 12,
|
|
font: helveticaBoldFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
page.drawText(certificateData.notary.name, {
|
|
x: leftMargin,
|
|
y: y,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
|
|
// Customer Information Section (Top Right)
|
|
let yRight = height - 40;
|
|
page.drawText('Fournisseur de Document:', {
|
|
x: rightSectionX,
|
|
y: yRight,
|
|
size: 12,
|
|
font: helveticaBoldFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
yRight -= lineSpacing;
|
|
page.drawText(`${certificateData.customer.firstName} ${certificateData.customer.lastName}`, {
|
|
x: rightSectionX,
|
|
y: yRight,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
yRight -= lineSpacing;
|
|
page.drawText(certificateData.customer.email, {
|
|
x: rightSectionX,
|
|
y: yRight,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
yRight -= lineSpacing;
|
|
page.drawText(certificateData.customer.postalAddress, {
|
|
x: rightSectionX,
|
|
y: yRight,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
|
|
// Centered Title
|
|
y -= 4 * lineSpacing; // Add more space between header and title
|
|
const titleText = 'Certificat de Validation de Document';
|
|
const titleWidth = helveticaBoldFont.widthOfTextAtSize(titleText, 20);
|
|
page.drawText(titleText, {
|
|
x: (width - titleWidth) / 2,
|
|
y: y,
|
|
size: 20,
|
|
font: helveticaBoldFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
|
|
// Add a line separator
|
|
page.drawLine({
|
|
start: { x: leftMargin, y: y },
|
|
end: { x: width - leftMargin, y: y },
|
|
thickness: 0.5,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
|
|
// Document Information Section
|
|
page.drawText('Informations du Document', {
|
|
x: leftMargin,
|
|
y: y,
|
|
size: 12,
|
|
font: helveticaBoldFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
page.drawText(`UID du Dossier: ${certificateData.folderUid}`, {
|
|
x: leftMargin,
|
|
y: y,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
page.drawText(`Type de Document: ${certificateData.metadata.documentType}`, {
|
|
x: leftMargin,
|
|
y: y,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
page.drawText(`Nom du fichier: ${certificateData.metadata.fileName}`, {
|
|
x: leftMargin,
|
|
y: y,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
page.drawText(`Créé le: ${certificateData.metadata.createdAt.toLocaleDateString('fr-FR')}`, {
|
|
x: leftMargin,
|
|
y: y,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
page.drawText(`Dernière modification: ${certificateData.metadata.updatedAt.toLocaleDateString('fr-FR')}`, {
|
|
x: leftMargin,
|
|
y: y,
|
|
size: 12,
|
|
font: helveticaFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
y -= lineSpacing;
|
|
|
|
const keywords = [
|
|
certificateData.documentHash,
|
|
certificateData.metadata.commitmentId,
|
|
JSON.stringify(certificateData.metadata.merkleProof)
|
|
];
|
|
pdfDoc.setKeywords([keywords.join(separator)]);
|
|
|
|
// Add explanatory text about certificate usage
|
|
const explanatoryText = "Ce certificat doit être utilisé avec le document qu'il certifie pour être vérifié sur la page dédiée. Le document correspondant à ce certificat doit être téléchargé depuis LeCoffre et peut être conservé avec le certificat tant qu'il n'est pas modifié.";
|
|
const explanatoryLines = this.splitTextToFit(helveticaObliqueFont, explanatoryText, width - (2 * leftMargin), 11);
|
|
y -= (explanatoryLines.length * 12) + 40; // Space after verification data
|
|
explanatoryLines.forEach((line, index) => {
|
|
page.drawText(line, {
|
|
x: leftMargin,
|
|
y: y - (index * 14), // Slightly more spacing for readability
|
|
size: 11,
|
|
font: helveticaObliqueFont,
|
|
color: rgb(0.3, 0.3, 0.3) // Slightly grayed out to distinguish from main content
|
|
});
|
|
});
|
|
|
|
// Footer
|
|
const footerText1 = `Page 1 sur 1`;
|
|
const footerText2 = `Généré le ${new Date().toLocaleString('fr-FR')}`;
|
|
const footerWidth1 = helveticaObliqueFont.widthOfTextAtSize(footerText1, 10);
|
|
const footerWidth2 = helveticaObliqueFont.widthOfTextAtSize(footerText2, 10);
|
|
page.drawText(footerText1, {
|
|
x: (width - footerWidth1) / 2,
|
|
y: 35,
|
|
size: 10,
|
|
font: helveticaObliqueFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
page.drawText(footerText2, {
|
|
x: (width - footerWidth2) / 2,
|
|
y: 25,
|
|
size: 10,
|
|
font: helveticaObliqueFont,
|
|
color: rgb(0, 0, 0)
|
|
});
|
|
|
|
const pdfBytes = await pdfDoc.save();
|
|
return new Blob([pdfBytes], { type: 'application/pdf' });
|
|
} catch (error) {
|
|
console.error('Error generating certificate:', error);
|
|
throw new Error('Failed to generate certificate');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Split text to fit within a specified width
|
|
* @param font - PDF font instance
|
|
* @param text - Text to split
|
|
* @param maxWidth - Maximum width in points
|
|
* @param fontSize - Font size
|
|
* @returns Array of text lines
|
|
*/
|
|
private splitTextToFit(font: any, text: string, maxWidth: number, fontSize: number): string[] {
|
|
const lines: string[] = [];
|
|
let currentLine = '';
|
|
|
|
// Split by characters (pdf-lib doesn't have word-level text measurement)
|
|
const chars = text.split('');
|
|
|
|
for (const char of chars) {
|
|
const testLine = currentLine + char;
|
|
const testWidth = font.widthOfTextAtSize(testLine, fontSize);
|
|
|
|
if (testWidth <= maxWidth) {
|
|
currentLine = testLine;
|
|
} else {
|
|
if (currentLine) {
|
|
lines.push(currentLine);
|
|
currentLine = char;
|
|
} else {
|
|
// If even a single character is too wide, force a break
|
|
lines.push(char);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (currentLine) {
|
|
lines.push(currentLine);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Download a certificate PDF
|
|
* @param certificateData - Data needed for the certificate
|
|
* @param filename - Optional filename for the download
|
|
*/
|
|
public async downloadCertificate(certificateData: CertificateData, filename?: string): Promise<void> {
|
|
try {
|
|
const pdfBlob = await this.generateCertificate(certificateData);
|
|
const defaultFilename = `certificate_${certificateData.metadata.documentUid}_${new Date().toISOString().split('T')[0]}.pdf`;
|
|
saveAs(pdfBlob, filename || defaultFilename);
|
|
} catch (error) {
|
|
console.error('Error downloading certificate:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a PDF certificate and extract document information
|
|
* @param pdfBlob - The PDF file as a blob
|
|
* @returns Promise<{documentHash: string, documentUid: string, commitmentId: string, merkleProof?: string}> - Extracted information
|
|
*/
|
|
public async parseCertificate(certificateFile: File): Promise<{ documentHash: string, commitmentId: string, merkleProof: any }> {
|
|
return new Promise((resolve, reject) => {
|
|
const fileReader = new FileReader();
|
|
|
|
fileReader.onload = async () => {
|
|
try {
|
|
// Read the metadata and get the validation data from the keywords
|
|
const pdfDoc = await PDFDocument.load(await certificateFile.arrayBuffer());
|
|
const keywords = pdfDoc.getKeywords()?.split(separator);
|
|
|
|
if (!keywords) {
|
|
throw new Error("No keywords found in certificate");
|
|
}
|
|
|
|
if (keywords.length !== 3) {
|
|
throw new Error("Invalid keywords found in certificate");
|
|
}
|
|
|
|
const documentHash = keywords[0];
|
|
const commitmentId = keywords[1];
|
|
const merkleProof = keywords[2];
|
|
|
|
if (!documentHash || !commitmentId || !merkleProof) {
|
|
throw new Error("Invalid keywords found in certificate");
|
|
}
|
|
|
|
resolve({ documentHash, commitmentId, merkleProof });
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
fileReader.onerror = () => {
|
|
reject(new Error('Failed to read PDF file'));
|
|
};
|
|
|
|
fileReader.readAsArrayBuffer(certificateFile);
|
|
});
|
|
}
|
|
}
|