Add FirmaEC signature style and bump to 1.2.17
This commit is contained in:
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -33,10 +33,30 @@ function truncate(text, font, size, maxWidth) {
|
||||
}
|
||||
|
||||
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)));
|
||||
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 } = {}) {
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user