const fs = require('fs/promises'); const path = require('path'); const QRCode = require('qrcode'); const { PDFDocument, PDFArray, PDFName, PDFNumber, rgb, StandardFonts } = require('pdf-lib'); const { pdflibAddPlaceholder } = require('@signpdf/placeholder-pdf-lib'); const { SignPdf } = require('@signpdf/signpdf'); const { P12Signer } = require('@signpdf/signer-p12'); const { getSignedDocumentPath } = require('../../utils/storagePath'); // ── Color helpers ────────────────────────────────────────────────────────────── const C = { primary: rgb(0.31, 0.27, 0.90), dark: rgb(0.10, 0.10, 0.30), gray: rgb(0.35, 0.35, 0.45), lightGray: rgb(0.60, 0.60, 0.65), bgBlue: rgb(0.95, 0.97, 1.00), bgDark: rgb(0.12, 0.14, 0.25), bgGray: rgb(0.96, 0.96, 0.97), white: rgb(1.00, 1.00, 1.00), accent: rgb(0.20, 0.60, 0.85), green: rgb(0.10, 0.55, 0.30), bgGreen: rgb(0.93, 0.98, 0.94), }; // ── Shared text truncation ───────────────────────────────────────────────────── function truncate(text, font, size, maxWidth) { if (!text) return ''; const ellipsis = '...'; if (font.widthOfTextAtSize(text, size) <= maxWidth) return text; let t = text; while (t.length > 0 && font.widthOfTextAtSize(t + ellipsis, size) > maxWidth) t = t.slice(0, -1); return t + ellipsis; } function getSignatureWidth(style, signerName = '') { if (style === 'qr' || style === 'firmaec') { const normalizedName = String(signerName).trim(); const calculatedWidth = 78 + (normalizedName.length * 4.9); return Math.max(170, Math.min(270, Math.round(calculatedWidth))); } return 200; } function wrapToLines(text, font, size, maxWidth, maxLines) { const words = text.split(' '); const lines = []; let current = ''; for (const word of words) { if (lines.length >= maxLines) break; const test = current ? `${current} ${word}` : word; if (font.widthOfTextAtSize(test, size) <= maxWidth) { current = test; } else { if (current) lines.push(current); current = truncate(word, font, size, maxWidth); } } if (current && lines.length < maxLines) lines.push(current); return lines; } function buildLocalizedSignatureTimestamp({ locale, timeZone } = {}) { const now = new Date(); const normalizedLocale = locale || 'es-EC'; const options = timeZone ? { timeZone } : {}; try { return { dateStr: now.toLocaleDateString(normalizedLocale, { day: '2-digit', month: '2-digit', year: 'numeric', ...options, }), timeStr: now.toLocaleTimeString(normalizedLocale, { hour: '2-digit', minute: '2-digit', hour12: true, ...options, }), }; } catch { return { dateStr: now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' }), timeStr: now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true }), }; } } function hideSignatureWidgetBorder(pdfDoc, pageIndex) { const page = pdfDoc.getPages()[pageIndex]; if (!page) return; const annotations = page.node.lookupMaybe(PDFName.of('Annots'), PDFArray); if (!annotations || annotations.size() === 0) return; const widgetRef = annotations.get(annotations.size() - 1); const widget = pdfDoc.context.lookup(widgetRef); if (!widget) return; const zeroBorder = PDFArray.withContext(pdfDoc.context); zeroBorder.push(PDFNumber.of(0)); zeroBorder.push(PDFNumber.of(0)); zeroBorder.push(PDFNumber.of(0)); widget.set(PDFName.of('Border'), zeroBorder); widget.set(PDFName.of('BS'), pdfDoc.context.obj({ Type: 'Border', W: 0, S: 'S', })); widget.set(PDFName.of('MK'), pdfDoc.context.obj({ BC: [], BG: [], })); } // ── Style: CLASSIC (blue bar + DS badge) ────────────────────────────────────── async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) { const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica); page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgBlue, borderColor: C.primary, borderWidth: 1 }); page.drawRectangle({ x: sx, y: sy, width: 4, height: sh, color: C.primary }); const nameSize = signerName.length > 24 ? 7 : 8.5; page.drawText('DS', { x: sx + 7, y: sy + sh - 18, size: 9, font: f1, color: C.primary }); page.drawText(truncate(signerName, f1, nameSize, sw - 30), { x: sx + 22, y: sy + sh - 17, size: nameSize, font: f1, color: C.dark }); if (reason) page.drawText(`Razon: ${truncate(reason, f2, 7, sw - 60)}`, { x: sx + 8, y: sy + sh - 30, size: 7, font: f2, color: C.gray }); page.drawText(`Fecha: ${dateStr} ${timeStr}`, { x: sx + 8, y: sy + sh - 41, size: 7, font: f2, color: C.gray }); if (location) page.drawText(`Lugar: ${truncate(location, f2, 7, sw - 60)}`, { x: sx + 8, y: sy + sh - 52, size: 7, font: f2, color: C.gray }); page.drawText('Firma digital', { x: sx + sw - 54, y: sy + 4, size: 6, font: f2, color: C.lightGray }); } // ── Style: QR CODE ───────────────────────────────────────────────────────────── async function drawStyleQR(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location, verificationUrl, qrPosition, qrBackground, qrBorder }) { const f1 = await pdfDoc.embedFont(StandardFonts.Helvetica); const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica); const useSoftBackground = qrBackground === 'soft'; const useBorder = qrBorder === 'line'; const qrContent = verificationUrl || [ 'FIRMA DIGITAL VERIFICADA', `Firmante: ${signerName}`, reason ? `Razon: ${reason}` : '', `Fecha: ${dateStr} ${timeStr}`, location ? `Lugar: ${location}` : '', ].filter(Boolean).join('\n'); // Generate QR as PNG buffer const qrPng = await QRCode.toBuffer(qrContent, { type: 'png', width: 100, margin: 1, color: { dark: '#1f1b6e', light: useSoftBackground ? '#f2f4ff' : '#0000' }, }); const qrImage = await pdfDoc.embedPng(qrPng); const qrSize = Math.min(sh - 10, 44); const qrOnRight = qrPosition === 'right'; const textPadding = 6; const textX = qrOnRight ? sx + textPadding : sx + qrSize + 6; const textW = sw - qrSize - 10; // Background if (useSoftBackground) { page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgBlue }); } if (useBorder) { page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, borderColor: C.primary, borderWidth: 1 }); } const qrX = qrOnRight ? sx + sw - qrSize - 2 : sx + 2; if (useSoftBackground) { page.drawRectangle({ x: qrOnRight ? sx + sw - qrSize - 4 : sx, y: sy, width: qrSize + 4, height: sh, color: C.bgBlue }); } page.drawImage(qrImage, { x: qrX, y: sy + 4, width: qrSize, height: qrSize }); const dividerX = qrOnRight ? sx + sw - qrSize - 4 : textX - 2; page.drawLine({ start: { x: dividerX, y: sy + 4 }, end: { x: dividerX, y: sy + sh - 4 }, thickness: 0.5, color: C.primary, opacity: 0.4 }); // Text on right const nameSize = signerName.length > 26 ? 6.8 : 7.6; page.drawText(truncate(signerName, f1, nameSize, textW), { x: textX, y: sy + sh - 13, size: nameSize, font: f1, color: C.dark }); if (reason) page.drawText(truncate(reason, f2, 6.2, textW), { x: textX, y: sy + sh - 23, size: 6.2, font: f2, color: C.gray }); page.drawText(`${dateStr}`, { x: textX, y: sy + sh - 33, size: 6.2, font: f2, color: C.gray }); page.drawText(`${timeStr}`, { x: textX, y: sy + sh - 43, size: 6.2, font: f2, color: C.gray }); if (location) page.drawText(truncate(location, f2, 5.6, textW), { x: textX, y: sy + sh - 52, size: 5.6, font: f2, color: C.lightGray }); page.drawText('Escanee el QR para verificar', { x: sx + 2, y: sy + 2, size: 5, font: f2, color: C.lightGray }); } // ── Style: PROFESSIONAL (dark header + fields) ──────────────────────────────── async function drawStyleProfessional(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) { const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica); const headerH = 16; // Background page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgGray, borderColor: rgb(0.70, 0.72, 0.78), borderWidth: 0.75 }); // Dark header bar page.drawRectangle({ x: sx, y: sy + sh - headerH, width: sw, height: headerH, color: C.bgDark }); page.drawText('FIRMA DIGITAL', { x: sx + 6, y: sy + sh - headerH + 4, size: 7.5, font: f1, color: C.white }); page.drawText('Firmado electronicamente', { x: sx + sw - 88, y: sy + sh - headerH + 4, size: 5.5, font: f2, color: rgb(0.7, 0.7, 0.85) }); // Separator line below header page.drawLine({ start: { x: sx, y: sy + sh - headerH - 1 }, end: { x: sx + sw, y: sy + sh - headerH - 1 }, thickness: 0.5, color: C.primary, opacity: 0.3 }); // Signer name (prominent) const nameSize = signerName.length > 28 ? 7.5 : 9; page.drawText(truncate(signerName, f1, nameSize, sw - 10), { x: sx + 6, y: sy + sh - headerH - 14, size: nameSize, font: f1, color: C.dark }); // Separator line const lineY = sy + sh - headerH - 20; page.drawLine({ start: { x: sx + 4, y: lineY }, end: { x: sx + sw - 4, y: lineY }, thickness: 0.4, color: C.lightGray, opacity: 0.6 }); // Fields in two columns const col1X = sx + 6; const col2X = sx + sw / 2 + 2; const fieldW = sw / 2 - 10; page.drawText('Razon:', { x: col1X, y: lineY - 11, size: 5.5, font: f1, color: C.gray }); page.drawText(truncate(reason || 'Firmado digitalmente', f2, 6, fieldW), { x: col1X, y: lineY - 20, size: 6, font: f2, color: C.dark }); page.drawText('Fecha:', { x: col2X, y: lineY - 11, size: 5.5, font: f1, color: C.gray }); page.drawText(`${dateStr}`, { x: col2X, y: lineY - 20, size: 6, font: f2, color: C.dark }); page.drawText(`${timeStr}`, { x: col2X, y: lineY - 28, size: 6, font: f2, color: C.dark }); if (location) { page.drawText('Lugar:', { x: col1X, y: lineY - 30, size: 5.5, font: f1, color: C.gray }); page.drawText(truncate(location, f2, 6, fieldW), { x: col1X, y: lineY - 39, size: 6, font: f2, color: C.dark }); } } // ── Style: IMAGE (custom handwritten image + info) ──────────────────────────── async function drawStyleImage(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location, signatureImageBase64 }) { const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica); // Background page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.white, borderColor: rgb(0.75, 0.75, 0.80), borderWidth: 0.75 }); page.drawRectangle({ x: sx, y: sy, width: 4, height: sh, color: C.green }); const imgAreaW = sw * 0.45; const imgAreaH = sh - 10; if (signatureImageBase64) { try { // Strip data URL prefix if present const base64Data = signatureImageBase64.replace(/^data:image\/\w+;base64,/, ''); const imgBuf = Buffer.from(base64Data, 'base64'); let embeddedImg; try { embeddedImg = await pdfDoc.embedPng(imgBuf); } catch { embeddedImg = await pdfDoc.embedJpg(imgBuf); } const scale = Math.min(imgAreaW / embeddedImg.width, imgAreaH / embeddedImg.height); const drawW = embeddedImg.width * scale; const drawH = embeddedImg.height * scale; page.drawImage(embeddedImg, { x: sx + 6 + (imgAreaW - drawW) / 2, y: sy + (sh - drawH) / 2, width: drawW, height: drawH, }); } catch { /* ignore embed errors */ } } else { // Placeholder if no image provided page.drawRectangle({ x: sx + 6, y: sy + 8, width: imgAreaW, height: imgAreaH, color: C.bgGray, borderColor: rgb(0.8, 0.8, 0.85), borderWidth: 0.5 }); page.drawText('Firma', { x: sx + 6 + imgAreaW / 2 - 10, y: sy + sh / 2 - 4, size: 7, font: f2, color: C.lightGray }); } // Vertical divider const divX = sx + imgAreaW + 8; page.drawLine({ start: { x: divX, y: sy + 4 }, end: { x: divX, y: sy + sh - 4 }, thickness: 0.5, color: rgb(0.8, 0.8, 0.85) }); // Text right side const textX = divX + 5; const textW = sw - imgAreaW - 16; const nameSize = signerName.length > 20 ? 7 : 8.5; page.drawText(truncate(signerName, f1, nameSize, textW), { x: textX, y: sy + sh - 14, size: nameSize, font: f1, color: C.dark }); page.drawLine({ start: { x: textX, y: sy + sh - 18 }, end: { x: textX + textW, y: sy + sh - 18 }, thickness: 0.4, color: C.green, opacity: 0.5 }); if (reason) page.drawText(truncate(reason, f2, 6.5, textW), { x: textX, y: sy + sh - 29, size: 6.5, font: f2, color: C.gray }); page.drawText(`${dateStr}`, { x: textX, y: sy + sh - 39, size: 6.5, font: f2, color: C.gray }); page.drawText(`${timeStr}`, { x: textX, y: sy + sh - 49, size: 6.5, font: f2, color: C.gray }); if (location) page.drawText(truncate(location, f2, 6, textW), { x: textX, y: sy + sh - 59, size: 6, font: f2, color: C.lightGray }); } // ── Style: FIRMAEC (QR izquierda + texto monoespaciado estilo FirmaEC Ecuador) ─ async function drawStyleFirmaEC(pdfDoc, page, sx, sy, sw, sh, { signerName, verificationUrl, qrPosition, qrBackground, qrBorder }) { const fBold = await pdfDoc.embedFont(StandardFonts.CourierBold); const fReg = await pdfDoc.embedFont(StandardFonts.Courier); const useSoftBackground = qrBackground === 'soft'; const useBorder = qrBorder === 'line'; const qrOnRight = qrPosition === 'right'; const qrContent = verificationUrl || [ 'FIRMA DIGITAL VERIFICADA', `Firmante: ${signerName}`, ].join('\n'); const qrPng = await QRCode.toBuffer(qrContent, { type: 'png', width: 120, margin: 1, color: { dark: '#000000', light: '#ffffff' }, }); const qrImage = await pdfDoc.embedPng(qrPng); const qrSize = Math.min(sh - 10, 50); // Background const bgColor = useSoftBackground ? C.bgBlue : C.white; page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: bgColor }); if (useBorder) { page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, borderColor: rgb(0.15, 0.15, 0.15), borderWidth: 0.85, }); } // QR image const qrX = qrOnRight ? sx + sw - qrSize - 4 : sx + 4; const qrY = sy + (sh - qrSize) / 2; page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize }); // Vertical divider belongs to the bordered variant only. const divX = qrOnRight ? qrX - 4 : sx + qrSize + 8; if (useBorder) { page.drawLine({ start: { x: divX, y: sy + 6 }, end: { x: divX, y: sy + sh - 6 }, thickness: 0.5, color: rgb(0.55, 0.55, 0.58) }); } // Text column const textX = qrOnRight ? sx + 6 : divX + 5; const textW = sw - qrSize - 18; page.drawText('Validar unicamente en FirmaEC.', { x: textX, y: sy + sh - 12, size: 6, font: fReg, color: rgb(0.12, 0.12, 0.14) }); page.drawText('Firmado electronicamente por:', { x: textX, y: sy + sh - 21, size: 6, font: fReg, color: rgb(0.12, 0.12, 0.14) }); // Name — bold, uppercase, word-wrapped up to 2 lines const nameUpper = signerName.toUpperCase(); const nameSize = 8.5; const nameLines = wrapToLines(nameUpper, fBold, nameSize, textW, 2); nameLines.forEach((line, i) => { page.drawText(line, { x: textX, y: sy + sh - 34 - (i * 13), size: nameSize, font: fBold, color: rgb(0.05, 0.05, 0.07) }); }); } // ── Main sign function ───────────────────────────────────────────────────────── const signPdf = async ({ pdfPath, p12Buffer, p12Password, signerName, reason = '', location = '', page = 0, rect = null, signatureStyle = 'classic', signatureImageBase64 = null, qrPosition = 'left', qrBackground = 'transparent', qrBorder = 'none', verificationUrl = null, clientLocale = null, clientTimeZone = null, outputDir = null, outputBaseName = null, }) => { const pdfBytes = await fs.readFile(pdfPath); const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); const pages = pdfDoc.getPages(); const tp = pages[Math.min(page, pages.length - 1)]; const { width: pw, height: ph } = tp.getSize(); const cropBox = typeof tp.getCropBox === 'function' ? tp.getCropBox() : { x: 0, y: 0, width: pw, height: ph }; const defaultWidth = getSignatureWidth(signatureStyle, signerName); const requestedRect = rect || { x: cropBox.width - (defaultWidth + 22), y: 18, width: defaultWidth, height: 65, }; const resolvedWidth = requestedRect.width || defaultWidth; const resolvedX = Math.min( Math.max(requestedRect.x, 0), Math.max(0, cropBox.width - resolvedWidth - 4) ); const resolvedY = Math.min( Math.max(requestedRect.y, 0), Math.max(0, cropBox.height - (requestedRect.height || 65) - 4) ); const sigRect = { ...requestedRect, x: resolvedX + cropBox.x, y: resolvedY + cropBox.y, width: resolvedWidth, }; const { x: sx, y: sy, width: sw, height: sh } = sigRect; const { dateStr, timeStr } = buildLocalizedSignatureTimestamp({ locale: clientLocale, timeZone: clientTimeZone, }); const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder }; // ── Draw visual stamp on page content stream ───────────────────── const style = signatureStyle || 'classic'; if (style === 'qr') await drawStyleQR(pdfDoc, tp, sx, sy, sw, sh, info); else if (style === 'professional') await drawStyleProfessional(pdfDoc, tp, sx, sy, sw, sh, info); else if (style === 'image') await drawStyleImage(pdfDoc, tp, sx, sy, sw, sh, info); else if (style === 'firmaec') await drawStyleFirmaEC(pdfDoc, tp, sx, sy, sw, sh, info); else await drawStyleClassic(pdfDoc, tp, sx, sy, sw, sh, info); // ── Save page with visual stamp ─────────────────────────────────── const pdfWithStamp = Buffer.from(await pdfDoc.save({ useObjectStreams: false })); // ── Add cryptographic signature placeholder ─────────────────────── const pdfDocForSign = await PDFDocument.load(pdfWithStamp); const placeholderPageIndex = Math.min(page, pdfDocForSign.getPageCount() - 1); pdflibAddPlaceholder({ pdfDoc: pdfDocForSign, reason: reason || 'Firma digital', contactInfo: signerName, name: signerName, location: location || '', signatureLength: 32768, rect: [sx, sy, sx + sw, sy + sh], pageIndex: placeholderPageIndex, }); hideSignatureWidgetBorder(pdfDocForSign, placeholderPageIndex); const pdfWithPlaceholder = Buffer.from(await pdfDocForSign.save({ useObjectStreams: false })); // ── Sign ────────────────────────────────────────────────────────── const signer = new P12Signer(p12Buffer, { passphrase: p12Password || '' }); const signInstance = new SignPdf(); const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer); // ── Save ────────────────────────────────────────────────────────── const destDir = outputDir || await getSignedDocumentPath(); const ext = path.extname(pdfPath); const base = outputBaseName || path.basename(pdfPath, ext).replace(/^\d+-/, ''); const nowForFilename = new Date(); const year = String(nowForFilename.getFullYear()); const month = String(nowForFilename.getMonth() + 1).padStart(2, '0'); const day = String(nowForFilename.getDate()).padStart(2, '0'); const hours = String(nowForFilename.getHours()).padStart(2, '0'); const minutes = String(nowForFilename.getMinutes()).padStart(2, '0'); const dateSuffix = `${year}${month}${day}${hours}${minutes}`; const signedFilename = outputBaseName ? `${base}${ext}` : `${base}_${dateSuffix}${ext}`; const signedPath = path.join(destDir, signedFilename); await fs.writeFile(signedPath, signedBuf); return { signedFilename, signedPath }; }; module.exports = { signPdf };