492 lines
21 KiB
JavaScript
492 lines
21 KiB
JavaScript
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 getFirmaEcNameLines(signerName, font, size, maxWidth) {
|
|
const words = String(signerName || '')
|
|
.trim()
|
|
.toUpperCase()
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
|
|
if (words.length >= 4) {
|
|
return [
|
|
words.slice(0, 2).join(' '),
|
|
words.slice(2).join(' '),
|
|
].map((line) => truncate(line, font, size, maxWidth));
|
|
}
|
|
|
|
return wrapToLines(words.join(' '), font, size, maxWidth, 2);
|
|
}
|
|
|
|
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('0 Validar unicamente en FirmaEC.', { x: textX, y: sy + sh - 12, size: 6, font: fReg, color: rgb(0.12, 0.12, 0.14) });
|
|
page.drawText('0 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 nameSize = 8.5;
|
|
const nameLines = getFirmaEcNameLines(signerName, fBold, nameSize, textW);
|
|
console.info('[FIRMAEC_PDF_RENDER]', JSON.stringify({
|
|
signerName,
|
|
qrPosition,
|
|
qrBackground,
|
|
qrBorder,
|
|
nameLines,
|
|
rect: { x: sx, y: sy, width: sw, height: sh },
|
|
}));
|
|
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) });
|
|
});
|
|
page.drawText('prueba', { x: textX, y: sy + 8, size: 6, font: fReg, color: rgb(0.18, 0.18, 0.18) });
|
|
}
|
|
|
|
// ── 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);
|
|
const placeholderPage = pdfDocForSign.getPages()[placeholderPageIndex];
|
|
const useInvisibleWidget = style === 'firmaec' && qrBorder === 'none';
|
|
const widgetRect = useInvisibleWidget
|
|
? [0, 0, 0, 0]
|
|
: [sx, sy, sx + sw, sy + sh];
|
|
pdflibAddPlaceholder({
|
|
pdfPage: placeholderPage,
|
|
reason: reason || 'Firma digital',
|
|
contactInfo: signerName,
|
|
name: signerName,
|
|
location: location || '',
|
|
signatureLength: 32768,
|
|
widgetRect,
|
|
});
|
|
if (!useInvisibleWidget) {
|
|
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 };
|