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 - The generated PDF as a blob */ public async generateCertificate(certificateData: CertificateData): Promise { 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 { 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); }); } }