Initial LinkedSign platform
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const QRCode = require('qrcode');
|
||||
const { PDFDocument, 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') return 200;
|
||||
const normalizedName = String(signerName).trim();
|
||||
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
||||
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
|
||||
}
|
||||
|
||||
// ── 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 });
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
}) => {
|
||||
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 now = new Date();
|
||||
const dateStr = now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const timeStr = now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
|
||||
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 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);
|
||||
pdflibAddPlaceholder({
|
||||
pdfDoc: pdfDocForSign,
|
||||
reason: reason || 'Firma digital',
|
||||
contactInfo: signerName,
|
||||
name: signerName,
|
||||
location: location || '',
|
||||
signatureLength: 32768,
|
||||
rect: [sx, sy, sx + sw, sy + sh],
|
||||
pageIndex: Math.min(page, pdfDocForSign.getPageCount() - 1),
|
||||
});
|
||||
|
||||
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 = await getSignedDocumentPath();
|
||||
const ext = path.extname(pdfPath);
|
||||
const base = 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 = `${base}_${dateSuffix}${ext}`;
|
||||
const signedPath = path.join(destDir, signedFilename);
|
||||
await fs.writeFile(signedPath, signedBuf);
|
||||
|
||||
return { signedFilename, signedPath };
|
||||
};
|
||||
|
||||
module.exports = { signPdf };
|
||||
Reference in New Issue
Block a user