Add FirmaEC signature style and bump to 1.2.17

This commit is contained in:
2026-05-21 22:07:22 -05:00
parent 5d7d610322
commit 8bc244855c
12 changed files with 199 additions and 16 deletions
+5 -1
View File
@@ -2,10 +2,14 @@
## Versión actual
- Versión base: `1.2.16`
- Versión base: `1.2.17`
## Historial
### 1.2.17 - 2026-05-21
- Web y backend agregan el estilo de firma `FirmaEC`, con vista previa configurable y sello visible con QR de validación.
### 1.2.16 - 2026-04-27
- Web aplica separación explícita entre `Certificado digital`, `Mostrar detalles` y `Elegir posición de firma` en `Firmar ahora`.
+1 -1
View File
@@ -2,7 +2,7 @@
## Versión actual
- Base actual: `1.2.16`
- Base actual: `1.2.17`
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
+1 -1
View File
@@ -2,7 +2,7 @@
## Versión actual
- Base actual: `1.2.16`
- Base actual: `1.2.17`
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
+1 -1
View File
@@ -2,7 +2,7 @@
## Versión actual
- Base actual: `1.2.16`
- Base actual: `1.2.17`
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
+1 -1
View File
@@ -1 +1 @@
1.2.16
1.2.17
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign"
minSdk 26
targetSdk 35
versionCode 10216
versionName "1.2.16"
versionCode 10217
versionName "1.2.17"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "linkedsign-backend",
"version": "1.2.16",
"version": "1.2.17",
"private": true,
"description": "Initial backend for LinkedSign",
"main": "src/server.js",
+76 -1
View File
@@ -33,11 +33,31 @@ function truncate(text, font, size, maxWidth) {
}
function getSignatureWidth(style, signerName = '') {
if (style !== 'qr') return 200;
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();
@@ -240,6 +260,60 @@ async function drawStyleImage(pdfDoc, page, sx, sy, sw, sh, { signerName, reason
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 });
const borderColor = useBorder ? rgb(0.15, 0.15, 0.15) : rgb(0.70, 0.70, 0.72);
const borderWidth = useBorder ? 0.85 : 0.5;
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, borderColor, borderWidth });
// 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
const divX = qrOnRight ? qrX - 4 : sx + qrSize + 8;
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,
@@ -300,6 +374,7 @@ const signPdf = async ({
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 ───────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
const usersRepository = require('./users.repository');
const ALLOWED_SIGNATURE_STYLES = ['classic', 'qr', 'professional'];
const ALLOWED_SIGNATURE_STYLES = ['classic', 'qr', 'professional', 'firmaec'];
const ALLOWED_QR_POSITIONS = ['left', 'right'];
const ALLOWED_QR_BACKGROUNDS = ['transparent', 'soft'];
const ALLOWED_QR_BORDERS = ['none', 'line'];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.2.16",
"version": "1.2.17",
"type": "module",
"scripts": {
"dev": "vite",
@@ -1,4 +1,4 @@
import { QrCode, Briefcase, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square } from 'lucide-react';
import { QrCode, Briefcase, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square, ShieldCheck } from 'lucide-react';
const getQrPreviewWidth = (name = '') => {
const extraWidth = Math.max(0, name.trim().length - 18) * 4;
@@ -84,6 +84,61 @@ const STYLES = [
</div>
),
},
{
id: 'firmaec',
label: 'FirmaEC',
icon: ShieldCheck,
desc: 'Formato oficial FirmaEC: QR de validación + nombre en negrita',
preview: ({ name, qrPosition = 'left', qrBackground = 'transparent', qrBorder = 'none' }) => (
<div
className="sig-preview sig-preview--firmaec"
data-background={qrBackground}
data-border={qrBorder}
>
{qrPosition === 'left' && (
<>
<div className="sp-qr-box sp-qr-box--firmaec" data-background={qrBackground}>
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
{[0,1,2,3,4,5,6,7,8,9].map(r =>
[0,1,2,3,4,5,6,7,8,9].map(c => {
const v = ((r * 3 + c * 7 + r ^ c) % 3 === 0);
return v ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill="#000" /> : null;
})
)}
<rect x={0} y={0} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
<rect x={7} y={0} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
<rect x={0} y={7} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
</svg>
</div>
<div className="sp-divider-v" />
</>
)}
<div className="sp-firmaec-text">
<span>Validar únicamente en FirmaEC.</span>
<span>Firmado electrónicamente por:</span>
<strong>{name.toUpperCase()}</strong>
</div>
{qrPosition === 'right' && (
<>
<div className="sp-divider-v" />
<div className="sp-qr-box sp-qr-box--firmaec" data-background={qrBackground}>
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
{[0,1,2,3,4,5,6,7,8,9].map(r =>
[0,1,2,3,4,5,6,7,8,9].map(c => {
const v = ((r * 3 + c * 7 + r ^ c) % 3 === 0);
return v ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill="#000" /> : null;
})
)}
<rect x={0} y={0} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
<rect x={7} y={0} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
<rect x={0} y={7} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
</svg>
</div>
</>
)}
</div>
),
},
{
id: 'professional',
label: 'Profesional',
@@ -145,7 +200,7 @@ export default function SignatureStylePicker({
})}
</div>
<p className="style-card-desc">{s.desc}</p>
{s.id === 'qr' && isActive && (
{(s.id === 'qr' || s.id === 'firmaec') && isActive && (
<div className="style-card-inline-tools">
<div className="style-inline-group">
<button
+49
View File
@@ -1868,6 +1868,55 @@ select {
flex-direction: column;
gap: 2px;
}
.sig-preview--firmaec {
display: flex;
align-items: stretch;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.12);
padding: 8px;
gap: 8px;
}
.sig-preview--firmaec[data-background='soft'] {
background: #eef2ff;
}
.sig-preview--firmaec[data-border='line'] {
border-color: rgba(0, 0, 0, 0.45);
border-width: 1.5px;
}
.sp-qr-box--firmaec {
background: #fff;
}
.sp-qr-box--firmaec[data-background='soft'] {
background: #f5f5f5;
}
.sp-firmaec-text {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
flex: 1;
min-width: 0;
font-family: 'Courier New', Courier, monospace;
}
.sp-firmaec-text span {
font-size: 8.5px;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sp-firmaec-text strong {
font-size: 11px;
font-weight: 800;
color: #0d0d0d;
white-space: normal;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-family: 'Courier New', Courier, monospace;
}
.sig-preview--image {
display: flex;
align-items: stretch;