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 actual
|
||||||
|
|
||||||
- Versión base: `1.2.16`
|
- Versión base: `1.2.17`
|
||||||
|
|
||||||
## Historial
|
## 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
|
### 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`.
|
- 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
|
## Versión actual
|
||||||
|
|
||||||
- Base actual: `1.2.16`
|
- Base actual: `1.2.17`
|
||||||
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
||||||
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Versión actual
|
## Versión actual
|
||||||
|
|
||||||
- Base actual: `1.2.16`
|
- Base actual: `1.2.17`
|
||||||
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
||||||
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Versión actual
|
## Versión actual
|
||||||
|
|
||||||
- Base actual: `1.2.16`
|
- Base actual: `1.2.17`
|
||||||
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
||||||
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ android {
|
|||||||
applicationId "net.linkedcorp.linkedsign"
|
applicationId "net.linkedcorp.linkedsign"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 10216
|
versionCode 10217
|
||||||
versionName "1.2.16"
|
versionName "1.2.17"
|
||||||
|
|
||||||
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkedsign-backend",
|
"name": "linkedsign-backend",
|
||||||
"version": "1.2.16",
|
"version": "1.2.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Initial backend for LinkedSign",
|
"description": "Initial backend for LinkedSign",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -33,11 +33,31 @@ function truncate(text, font, size, maxWidth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSignatureWidth(style, signerName = '') {
|
function getSignatureWidth(style, signerName = '') {
|
||||||
if (style !== 'qr') return 200;
|
if (style === 'qr' || style === 'firmaec') {
|
||||||
const normalizedName = String(signerName).trim();
|
const normalizedName = String(signerName).trim();
|
||||||
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
||||||
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
|
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 } = {}) {
|
function buildLocalizedSignatureTimestamp({ locale, timeZone } = {}) {
|
||||||
const now = new Date();
|
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 });
|
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 ─────────────────────────────────────────────────────────
|
// ── Main sign function ─────────────────────────────────────────────────────────
|
||||||
const signPdf = async ({
|
const signPdf = async ({
|
||||||
pdfPath, p12Buffer, p12Password,
|
pdfPath, p12Buffer, p12Password,
|
||||||
@@ -300,6 +374,7 @@ const signPdf = async ({
|
|||||||
if (style === 'qr') await drawStyleQR(pdfDoc, tp, sx, sy, sw, sh, info);
|
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 === '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 === '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);
|
else await drawStyleClassic(pdfDoc, tp, sx, sy, sw, sh, info);
|
||||||
|
|
||||||
// ── Save page with visual stamp ───────────────────────────────────
|
// ── Save page with visual stamp ───────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const usersRepository = require('./users.repository');
|
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_POSITIONS = ['left', 'right'];
|
||||||
const ALLOWED_QR_BACKGROUNDS = ['transparent', 'soft'];
|
const ALLOWED_QR_BACKGROUNDS = ['transparent', 'soft'];
|
||||||
const ALLOWED_QR_BORDERS = ['none', 'line'];
|
const ALLOWED_QR_BORDERS = ['none', 'line'];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.16",
|
"version": "1.2.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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 getQrPreviewWidth = (name = '') => {
|
||||||
const extraWidth = Math.max(0, name.trim().length - 18) * 4;
|
const extraWidth = Math.max(0, name.trim().length - 18) * 4;
|
||||||
@@ -84,6 +84,61 @@ const STYLES = [
|
|||||||
</div>
|
</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',
|
id: 'professional',
|
||||||
label: 'Profesional',
|
label: 'Profesional',
|
||||||
@@ -145,7 +200,7 @@ export default function SignatureStylePicker({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="style-card-desc">{s.desc}</p>
|
<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-card-inline-tools">
|
||||||
<div className="style-inline-group">
|
<div className="style-inline-group">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1868,6 +1868,55 @@ select {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
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 {
|
.sig-preview--image {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
Reference in New Issue
Block a user