feat: add SDK support for PDF document signing and visual seal customization tools
This commit is contained in:
+421
-112
@@ -7,6 +7,12 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
if (window.pdfjsLib) {
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-base: #090d16;
|
||||
@@ -615,10 +621,13 @@
|
||||
<!-- Abas de Navegação -->
|
||||
<div class="tabs-nav">
|
||||
<button class="tab-btn active" data-tab="tab-pdf">
|
||||
<span>📄</span> Assinar PDF
|
||||
<span>📄</span> Assinar PDF (Com Stamp Picker)
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tab-seals">
|
||||
<span>🎨</span> Designer de Selos
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tab-xml">
|
||||
<span>📋</span> Assinar XML (NF-e)
|
||||
<span>📋</span> Assinar XML (XAdES / NF-e)
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tab-file">
|
||||
<span>📁</span> Arquivos em Geral
|
||||
@@ -628,19 +637,19 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ABA 1: ASSINAR PDF -->
|
||||
<!-- ABA 1: ASSINAR PDF (INTERATIVO COM RETORNO EM BASE64) -->
|
||||
<div id="tab-pdf" class="tab-pane active">
|
||||
<div class="card-grid">
|
||||
<div class="card-grid" style="grid-template-columns: 1fr;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title"><span>📄</span> Seleção do Documento PDF</span>
|
||||
<span class="badge-tag">PAdES / Digest</span>
|
||||
<span class="card-title"><span>📄</span> Seleção do Documento & Posicionador de Selo (Stamp Picker)</span>
|
||||
<span class="badge-tag">PAdES ISO 32000 / Base64</span>
|
||||
</div>
|
||||
|
||||
<div id="pdfDropZone" class="drop-zone">
|
||||
<div class="drop-icon">📑</div>
|
||||
<p style="font-size: 13px; font-weight: 600; color: #fff;">Arraste um PDF ou clique para selecionar</p>
|
||||
<p style="font-size: 11px; color: var(--text-muted);">Suporta qualquer documento .pdf</p>
|
||||
<p style="font-size: 11px; color: var(--text-muted);">O documento será renderizado abaixo para você escolher onde posicionar o selo</p>
|
||||
<input type="file" id="pdfFileInput" accept="application/pdf" style="display: none;">
|
||||
</div>
|
||||
|
||||
@@ -649,80 +658,157 @@
|
||||
<strong id="pdfFileName" style="color: #fff;">contrato_prestacao.pdf</strong>
|
||||
<div id="pdfFileSize" style="color: var(--text-muted); font-size: 11px;">0 KB</div>
|
||||
</div>
|
||||
<span class="badge-tag">PDF</span>
|
||||
<span class="badge-tag">PDF Carregado</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Digest SHA-256 Calculado no Navegador (Base64):</label>
|
||||
<div id="pdfDigestBox" class="code-box">Selecione um arquivo PDF para calcular o digest...</div>
|
||||
<!-- Contêiner do PdfStampPicker -->
|
||||
<div id="stampPickerMount" style="margin-top: 14px; display: none;"></div>
|
||||
|
||||
<!-- Configurações do Selo e da Assinatura -->
|
||||
<div id="pdfOptionsSection" style="margin-top: 16px; display: none; background: var(--bg-surface); padding: 16px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);">
|
||||
<h4 style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 12px;">🎨 Configurações do Selo Visual</h4>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tema do Selo do Agente:</label>
|
||||
<select id="pdfSealThemeSelect" class="cert-bar-select">
|
||||
<option value="icp-brasil">ICP-Brasil Institucional (Padrão)</option>
|
||||
<option value="gov-br">Governamental (Gov.br / ITI)</option>
|
||||
<option value="cybercore-modern">CyberCore Moderno</option>
|
||||
<option value="minimal-clean">Minimalista Clássico</option>
|
||||
<option value="custom">🎨 Selo Customizado do Designer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Razão / Motivo da Assinatura:</label>
|
||||
<input type="text" id="pdfReasonInput" value="Documento aprovado e assinado digitalmente" placeholder="Ex: De acordo com os termos">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Localização:</label>
|
||||
<input type="text" id="pdfLocationInput" value="Curitiba, PR - Brasil" placeholder="Ex: Brasília, DF">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button id="btnSignPdfFull" class="btn btn-primary" style="flex: 2; padding: 12px; font-size: 13px;" disabled>
|
||||
<span>✍️</span> Assinar PDF Completo no Agente (Devolve Base64)
|
||||
</button>
|
||||
<button id="btnSignPdfDigest" class="btn btn-secondary" style="flex: 1; padding: 12px; font-size: 12px;" disabled>
|
||||
<span>🛡️</span> Assinar Apenas Digest (PAdES Remoto)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btnSignPdf" class="btn btn-primary" style="padding: 12px;" disabled>
|
||||
<span>✍️</span> Assinar PDF com CyberCore
|
||||
</button>
|
||||
|
||||
<div id="pdfAlert" class="alert alert-info" style="display: none;"></div>
|
||||
<div id="pdfAlert" class="alert alert-info" style="display: none; margin-top: 14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- Card de Resultado do PDF Assinado -->
|
||||
<div id="pdfResultCard" class="card" style="display: none;">
|
||||
<div class="card-header">
|
||||
<span class="card-title"><span>🛡️</span> Resultado da Assinatura Digital</span>
|
||||
<span class="card-title"><span>🎉</span> PDF Assinado Retornado com Sucesso</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="btnDownloadPdf" class="btn btn-primary" style="padding: 6px 14px; font-size: 12px;">
|
||||
<span>💾</span> Baixar PDF Assinado (.pdf)
|
||||
</button>
|
||||
<button id="btnCopyPdfBase64" class="btn btn-secondary" style="padding: 6px 12px; font-size: 12px;">
|
||||
<span>📋</span> Copiar Base64
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin-bottom: 14px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status da Operação:</label>
|
||||
<div id="pdfResultStatus" style="font-weight: 700; color: var(--success); font-size: 13px;">Assinado com Sucesso</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tamanho do PDF Assinado:</label>
|
||||
<div id="pdfResultSize" style="font-family: var(--font-mono); font-size: 12px; color: #fff;">0 KB</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SHA-256 do Documento Final:</label>
|
||||
<div id="pdfResultSha256" style="font-family: var(--font-mono); font-size: 11px; color: var(--primary); word-break: break-all;">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Assinatura Digital Retornada (Base64):</label>
|
||||
<div id="pdfSigOutput" class="code-box" style="min-height: 120px;">Aguardando assinatura do PDF...</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Cadeia do Certificado Digital X.509:</label>
|
||||
<div id="pdfCertOutput" class="code-box">Nenhum certificado emitido ainda.</div>
|
||||
<label class="form-label">Conteúdo do PDF Assinado em Base64 (pronto para envio ao Backend):</label>
|
||||
<div id="pdfResultBase64" class="code-box" style="max-height: 140px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABA 2: ASSINAR XML -->
|
||||
<!-- ABA 2: DESIGNER DE SELOS VISUAIS -->
|
||||
<div id="tab-seals" class="tab-pane">
|
||||
<div class="card-grid" style="grid-template-columns: 1fr;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title"><span>🎨</span> Designer Interativo de Selos Visuais</span>
|
||||
<span class="badge-tag">Customização em Tempo Real</span>
|
||||
</div>
|
||||
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 14px;">
|
||||
Crie, personalize cores, tipografia, logotipos e metadados de selos visuais. As configurações geradas aqui podem ser enviadas diretamente na requisição de assinatura de PDF (<code style="color: var(--primary);">seal_custom</code>) ou salvas no sistema.
|
||||
</p>
|
||||
|
||||
<div id="sealDesignerMount"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABA 3: ASSINAR XML -->
|
||||
<div id="tab-xml" class="tab-pane">
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title"><span>📋</span> Conteúdo do XML (NF-e / XMLDSig)</span>
|
||||
<span class="card-title"><span>📋</span> Conteúdo do XML (NF-e / CT-e / NFS-e)</span>
|
||||
<button id="btnLoadXmlSample" class="btn btn-secondary" style="padding: 4px 10px; font-size: 11px;">
|
||||
Carregar Exemplo NF-e
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Estrutura XML para Assinatura:</label>
|
||||
<textarea id="xmlContentInput" rows="7" placeholder="Cole o XML ou selecione um arquivo..."></textarea>
|
||||
<label class="form-label">Perfil de Assinatura XML:</label>
|
||||
<select id="xmlProfileSelect" class="cert-bar-select">
|
||||
<option value="XADES_BES">XAdES-BES (Padrão Oficial ICP-Brasil / NF-e)</option>
|
||||
<option value="XMLDSIG">XMLDSig W3C Padrão (Enveloped)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Digest SHA-256 Calculado no Navegador (Base64):</label>
|
||||
<div id="xmlDigestBox" class="code-box">Aguardando XML...</div>
|
||||
<label class="form-label">Estrutura XML para Assinatura:</label>
|
||||
<textarea id="xmlContentInput" rows="8" placeholder="Cole o XML ou selecione um arquivo..."></textarea>
|
||||
</div>
|
||||
|
||||
<button id="btnSignXml" class="btn btn-primary" style="padding: 12px;" disabled>
|
||||
<span>✍️</span> Assinar XML com CyberCore
|
||||
</button>
|
||||
<div style="display: flex; gap: 10px; margin-top: 4px;">
|
||||
<button id="btnSignXmlFull" class="btn btn-primary" style="flex: 2; padding: 12px;" disabled>
|
||||
<span>✍️</span> Assinar XML Completo no Agente
|
||||
</button>
|
||||
<button id="btnSignXmlDigest" class="btn btn-secondary" style="flex: 1; padding: 12px; font-size: 12px;" disabled>
|
||||
<span>🛡️</span> Apenas Digest
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="xmlAlert" class="alert alert-info" style="display: none;"></div>
|
||||
<div id="xmlAlert" class="alert alert-info" style="display: none; margin-top: 14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title"><span>🛡️</span> Pacote XML Assinado (XMLDSig)</span>
|
||||
<span class="card-title"><span>🛡️</span> Pacote XML Assinado</span>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button id="btnDownloadXml" class="btn btn-primary" style="padding: 4px 10px; font-size: 11px; display: none;">
|
||||
<span>💾</span> Baixar XML
|
||||
</button>
|
||||
<button id="btnCopyXml" class="btn btn-secondary" style="padding: 4px 10px; font-size: 11px; display: none;">
|
||||
<span>📋</span> Copiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bloco SignatureValue (Base64):</label>
|
||||
<div id="xmlSigOutput" class="code-box" style="min-height: 120px;">Aguardando assinatura do XML...</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tag XMLDSig Incorporável:</label>
|
||||
<div id="xmlTagOutput" class="code-box"><Signature xmlns="http://www.w3.org/2000/09/xmldsig#">...</Signature></div>
|
||||
<label class="form-label">XML Assinado Completo (com envelope de assinatura):</label>
|
||||
<div id="xmlSigOutput" class="code-box" style="min-height: 220px; max-height: 380px;">Aguardando submissão do XML para assinatura...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -851,7 +937,7 @@
|
||||
|
||||
<!-- Lógica Javascript com o SDK -->
|
||||
<script type="module">
|
||||
import { CyberCoreSigner } from '../src/signer-sdk.js';
|
||||
import { CyberCoreSigner, PdfStampPicker, PdfSealDesigner } from '../src/signer-sdk.js';
|
||||
|
||||
const signer = new CyberCoreSigner({
|
||||
baseUrl: 'http://127.0.0.1:21325',
|
||||
@@ -870,6 +956,16 @@
|
||||
let currentCertificates = [];
|
||||
let selectedCertificateId = null;
|
||||
|
||||
// Componentes e Estado
|
||||
let pdfStampPicker = null;
|
||||
let pdfSealDesigner = null;
|
||||
let currentPdfFile = null;
|
||||
let currentPdfBytes = null;
|
||||
let currentPdfDigest = null;
|
||||
let currentCustomSealConfig = null;
|
||||
let currentSignedPdfBase64 = null;
|
||||
let currentSignedXml = null;
|
||||
|
||||
// Gerenciador de Abas
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
const tabPanes = document.querySelectorAll('.tab-pane');
|
||||
@@ -879,7 +975,13 @@
|
||||
tabButtons.forEach(b => b.classList.remove('active'));
|
||||
tabPanes.forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(btn.dataset.tab).classList.add('active');
|
||||
const targetTab = btn.dataset.tab;
|
||||
document.getElementById(targetTab).classList.add('active');
|
||||
|
||||
// Lazy-mount do Designer de Selos
|
||||
if (targetTab === 'tab-seals' && !pdfSealDesigner) {
|
||||
initSealDesigner();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -900,6 +1002,29 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
// Carregamento de Temas de Selos
|
||||
async function loadSealThemes() {
|
||||
try {
|
||||
const themeSelect = document.getElementById('pdfSealThemeSelect');
|
||||
const themes = await signer.getSealThemes();
|
||||
if (themes && themes.length > 0) {
|
||||
themeSelect.innerHTML = '';
|
||||
themes.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id;
|
||||
opt.textContent = `${t.name} (${t.id})`;
|
||||
themeSelect.appendChild(opt);
|
||||
});
|
||||
const customOpt = document.createElement('option');
|
||||
customOpt.value = 'custom';
|
||||
customOpt.textContent = '🎨 Selo Customizado do Designer';
|
||||
themeSelect.appendChild(customOpt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Não foi possível carregar temas dinâmicos de selos:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Carregamento de Certificados
|
||||
async function loadCertificates() {
|
||||
try {
|
||||
@@ -913,7 +1038,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
certs.forEach((c, index) => {
|
||||
certs.forEach((c) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = `${c.common_name || c.alias} [${c.type}] (${c.device_name || c.provider_id})`;
|
||||
@@ -949,10 +1074,24 @@
|
||||
|
||||
function updateButtonStates() {
|
||||
const hasCert = Boolean(selectedCertificateId);
|
||||
document.getElementById('btnSignPdf').disabled = !hasCert || !currentPdfDigest;
|
||||
document.getElementById('btnSignXml').disabled = !hasCert || !currentXmlDigest;
|
||||
document.getElementById('btnSignGen').disabled = !hasCert || !currentGenDigest;
|
||||
document.getElementById('btnAuthLogin').disabled = !hasCert;
|
||||
const hasPdf = Boolean(currentPdfBytes);
|
||||
const hasXml = Boolean(document.getElementById('xmlContentInput').value.trim());
|
||||
|
||||
const btnFull = document.getElementById('btnSignPdfFull');
|
||||
const btnDigest = document.getElementById('btnSignPdfDigest');
|
||||
if (btnFull) btnFull.disabled = !hasCert || !hasPdf;
|
||||
if (btnDigest) btnDigest.disabled = !hasCert || !hasPdf;
|
||||
|
||||
const btnXmlFull = document.getElementById('btnSignXmlFull');
|
||||
const btnXmlDigest = document.getElementById('btnSignXmlDigest');
|
||||
if (btnXmlFull) btnXmlFull.disabled = !hasCert || !hasXml;
|
||||
if (btnXmlDigest) btnXmlDigest.disabled = !hasCert || !hasXml;
|
||||
|
||||
const btnGen = document.getElementById('btnSignGen');
|
||||
if (btnGen) btnGen.disabled = !hasCert || !currentGenDigest;
|
||||
|
||||
const btnAuth = document.getElementById('btnAuthLogin');
|
||||
if (btnAuth) btnAuth.disabled = !hasCert;
|
||||
}
|
||||
|
||||
// Conexão e Handshake
|
||||
@@ -964,6 +1103,7 @@
|
||||
statusDot.classList.add('online');
|
||||
statusText.textContent = 'Conectado & Autorizado';
|
||||
await loadCertificates();
|
||||
await loadSealThemes();
|
||||
} catch (err) {
|
||||
alert('Erro ao conectar: ' + err.message);
|
||||
} finally {
|
||||
@@ -976,25 +1116,31 @@
|
||||
const online = await checkAgentStatus();
|
||||
if (online) {
|
||||
await loadCertificates();
|
||||
await loadSealThemes();
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// ABA 1: LÓGICA DE PDF
|
||||
// ABA 1: LÓGICA DE PDF (STAMP PICKER & ASSINATURA BASE64)
|
||||
// =========================================================================
|
||||
const pdfDropZone = document.getElementById('pdfDropZone');
|
||||
const pdfFileInput = document.getElementById('pdfFileInput');
|
||||
const pdfFileMeta = document.getElementById('pdfFileMeta');
|
||||
const pdfFileName = document.getElementById('pdfFileName');
|
||||
const pdfFileSize = document.getElementById('pdfFileSize');
|
||||
const pdfDigestBox = document.getElementById('pdfDigestBox');
|
||||
const btnSignPdf = document.getElementById('btnSignPdf');
|
||||
const stampPickerMount = document.getElementById('stampPickerMount');
|
||||
const pdfOptionsSection = document.getElementById('pdfOptionsSection');
|
||||
const pdfSealThemeSelect = document.getElementById('pdfSealThemeSelect');
|
||||
const btnSignPdfFull = document.getElementById('btnSignPdfFull');
|
||||
const btnSignPdfDigest = document.getElementById('btnSignPdfDigest');
|
||||
const pdfAlert = document.getElementById('pdfAlert');
|
||||
const pdfSigOutput = document.getElementById('pdfSigOutput');
|
||||
const pdfCertOutput = document.getElementById('pdfCertOutput');
|
||||
|
||||
let currentPdfFile = null;
|
||||
let currentPdfDigest = null;
|
||||
const pdfResultCard = document.getElementById('pdfResultCard');
|
||||
const pdfResultSize = document.getElementById('pdfResultSize');
|
||||
const pdfResultSha256 = document.getElementById('pdfResultSha256');
|
||||
const pdfResultBase64 = document.getElementById('pdfResultBase64');
|
||||
const btnDownloadPdf = document.getElementById('btnDownloadPdf');
|
||||
const btnCopyPdfBase64 = document.getElementById('btnCopyPdfBase64');
|
||||
|
||||
pdfDropZone.addEventListener('click', () => pdfFileInput.click());
|
||||
pdfFileInput.addEventListener('change', e => {
|
||||
@@ -1019,17 +1165,84 @@
|
||||
pdfFileMeta.style.display = 'flex';
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
currentPdfBytes = buffer;
|
||||
currentPdfDigest = await signer._calculateSha256(buffer);
|
||||
pdfDigestBox.textContent = `SHA-256 (Base64): ${currentPdfDigest}\nArquivo: ${file.name} (${file.size} bytes)`;
|
||||
|
||||
stampPickerMount.style.display = 'block';
|
||||
pdfOptionsSection.style.display = 'block';
|
||||
|
||||
if (!pdfStampPicker) {
|
||||
pdfStampPicker = new PdfStampPicker({
|
||||
container: stampPickerMount,
|
||||
pdfSource: buffer,
|
||||
initialScale: 1.0,
|
||||
onPositionChange: (pos) => {
|
||||
console.log('Posição do selo atualizada:', pos);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
pdfStampPicker.loadPdf(buffer);
|
||||
}
|
||||
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
btnSignPdf.addEventListener('click', async () => {
|
||||
btnSignPdf.disabled = true;
|
||||
btnSignPdf.innerHTML = '<span class="spinner"></span> Solicitando no Agente...';
|
||||
// Assinatura Completa de PDF (Devolve PDF Assinado Base64)
|
||||
btnSignPdfFull.addEventListener('click', async () => {
|
||||
btnSignPdfFull.disabled = true;
|
||||
btnSignPdfFull.innerHTML = '<span class="spinner"></span> Assinando no Agente...';
|
||||
pdfAlert.style.display = 'block';
|
||||
pdfAlert.className = 'alert alert-info';
|
||||
pdfAlert.textContent = 'Confirme a assinatura na janela do CyberCore Signer aberta no seu computador...';
|
||||
pdfAlert.textContent = 'Confirme a assinatura na janela do CyberCore Signer...';
|
||||
|
||||
try {
|
||||
const stampConfig = pdfStampPicker ? pdfStampPicker.getStampConfig() : { page: 1, x: 50, y: 50, scale: 1.0 };
|
||||
const selectedTheme = pdfSealThemeSelect.value;
|
||||
|
||||
const signParams = {
|
||||
certificateId: selectedCertificateId,
|
||||
pdf: currentPdfBytes,
|
||||
page: stampConfig.page,
|
||||
x: stampConfig.x,
|
||||
y: stampConfig.y,
|
||||
scale: stampConfig.scale,
|
||||
reason: document.getElementById('pdfReasonInput').value || 'Assinado Digitalmente',
|
||||
location: document.getElementById('pdfLocationInput').value || 'Brasil'
|
||||
};
|
||||
|
||||
if (selectedTheme === 'custom' && currentCustomSealConfig) {
|
||||
signParams.seal_custom = currentCustomSealConfig;
|
||||
} else {
|
||||
signParams.seal_theme_id = selectedTheme === 'custom' ? 'icp-brasil' : selectedTheme;
|
||||
}
|
||||
|
||||
const result = await signer.signPdfDocument(signParams);
|
||||
|
||||
pdfAlert.className = 'alert alert-success';
|
||||
pdfAlert.textContent = '✓ Documento PDF assinado com sucesso via PAdES (ISO 32000)!';
|
||||
|
||||
currentSignedPdfBase64 = result.signed_pdf;
|
||||
pdfResultCard.style.display = 'block';
|
||||
pdfResultSize.textContent = (result.signed_pdf.length * 0.75 / 1024).toFixed(1) + ' KB';
|
||||
pdfResultSha256.textContent = result.sha256 || 'Calculado com sucesso';
|
||||
pdfResultBase64.textContent = result.signed_pdf;
|
||||
pdfResultCard.scrollIntoView({ behavior: 'smooth' });
|
||||
} catch (err) {
|
||||
pdfAlert.className = 'alert alert-error';
|
||||
pdfAlert.textContent = 'Erro ao assinar PDF: ' + err.message;
|
||||
} finally {
|
||||
btnSignPdfFull.disabled = false;
|
||||
btnSignPdfFull.innerHTML = '<span>✍️</span> Assinar PDF Completo no Agente (Devolve Base64)';
|
||||
}
|
||||
});
|
||||
|
||||
// Assinatura via Digest (Fluxo Tradicional)
|
||||
btnSignPdfDigest.addEventListener('click', async () => {
|
||||
btnSignPdfDigest.disabled = true;
|
||||
btnSignPdfDigest.innerHTML = '<span class="spinner"></span> Solicitando...';
|
||||
pdfAlert.style.display = 'block';
|
||||
pdfAlert.className = 'alert alert-info';
|
||||
pdfAlert.textContent = 'Assinando digest do PDF no CyberCore Signer...';
|
||||
|
||||
try {
|
||||
const result = await signer.signPdf(currentPdfFile, {
|
||||
@@ -1038,30 +1251,97 @@
|
||||
});
|
||||
|
||||
pdfAlert.className = 'alert alert-success';
|
||||
pdfAlert.textContent = `✓ Documento PDF assinado com sucesso via ${result.algorithm}!`;
|
||||
pdfSigOutput.textContent = result.signature;
|
||||
pdfCertOutput.textContent = result.certificate;
|
||||
pdfAlert.textContent = `✓ Digest assinado com sucesso! Algoritmo: ${result.algorithm}`;
|
||||
} catch (err) {
|
||||
pdfAlert.className = 'alert alert-error';
|
||||
pdfAlert.textContent = 'Erro ao assinar PDF: ' + err.message;
|
||||
pdfAlert.textContent = 'Erro ao assinar digest: ' + err.message;
|
||||
} finally {
|
||||
btnSignPdf.disabled = false;
|
||||
btnSignPdf.innerHTML = '<span>✍️</span> Assinar PDF com CyberCore';
|
||||
btnSignPdfDigest.disabled = false;
|
||||
btnSignPdfDigest.innerHTML = '<span>🛡️</span> Assinar Apenas Digest (PAdES Remoto)';
|
||||
}
|
||||
});
|
||||
|
||||
// Download do PDF Assinado
|
||||
btnDownloadPdf.addEventListener('click', () => {
|
||||
if (!currentSignedPdfBase64) return;
|
||||
const blob = signer._base64ToBlob(currentSignedPdfBase64, 'application/pdf');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const origName = currentPdfFile ? currentPdfFile.name.replace(/\.pdf$/i, '') : 'documento';
|
||||
a.href = url;
|
||||
a.download = `${origName}_assinado.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Cópia do Base64 do PDF
|
||||
btnCopyPdfBase64.addEventListener('click', async () => {
|
||||
if (!currentSignedPdfBase64) return;
|
||||
await navigator.clipboard.writeText(currentSignedPdfBase64);
|
||||
const origText = btnCopyPdfBase64.innerHTML;
|
||||
btnCopyPdfBase64.innerHTML = '<span>✓</span> Copiado!';
|
||||
setTimeout(() => { btnCopyPdfBase64.innerHTML = origText; }, 2000);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// ABA 2: LÓGICA DE XML
|
||||
// ABA 2: DESIGNER DE SELOS VISUAIS
|
||||
// =========================================================================
|
||||
function initSealDesigner() {
|
||||
const mount = document.getElementById('sealDesignerMount');
|
||||
pdfSealDesigner = new PdfSealDesigner({
|
||||
container: mount,
|
||||
initialConfig: {
|
||||
name: 'Selo Customizado',
|
||||
title: 'ASSINADO DIGITALMENTE',
|
||||
primary_color: '#0284c7',
|
||||
secondary_color: '#10b981',
|
||||
background_color: '#ffffff',
|
||||
text_color: '#0f172a',
|
||||
border_color: '#38bdf8',
|
||||
icon_style: 'shield',
|
||||
default_cpf_mode: 'MASKED',
|
||||
default_include_date_time: true
|
||||
},
|
||||
onConfigChange: (cfg) => {
|
||||
currentCustomSealConfig = cfg;
|
||||
}
|
||||
});
|
||||
|
||||
// Botão para aplicar o selo na aba de PDF
|
||||
const applyBtnWrap = document.createElement('div');
|
||||
applyBtnWrap.style.marginTop = '16px';
|
||||
applyBtnWrap.style.display = 'flex';
|
||||
applyBtnWrap.style.gap = '10px';
|
||||
applyBtnWrap.innerHTML = `
|
||||
<button id="btnApplySealToPdf" class="btn btn-primary" style="padding: 10px 18px; font-size: 13px;">
|
||||
<span>🚀</span> Usar este Selo no Posicionador de PDF
|
||||
</button>
|
||||
`;
|
||||
mount.appendChild(applyBtnWrap);
|
||||
|
||||
document.getElementById('btnApplySealToPdf').addEventListener('click', () => {
|
||||
currentCustomSealConfig = pdfSealDesigner.getConfig();
|
||||
pdfSealThemeSelect.value = 'custom';
|
||||
// Alterna para aba PDF
|
||||
document.querySelector('[data-tab="tab-pdf"]').click();
|
||||
alert('Selo customizado selecionado para a assinatura do PDF!');
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ABA 3: LÓGICA DE XML (XAdES-BES & XMLDSig)
|
||||
// =========================================================================
|
||||
const xmlContentInput = document.getElementById('xmlContentInput');
|
||||
const xmlDigestBox = document.getElementById('xmlDigestBox');
|
||||
const btnSignXml = document.getElementById('btnSignXml');
|
||||
const xmlProfileSelect = document.getElementById('xmlProfileSelect');
|
||||
const btnSignXmlFull = document.getElementById('btnSignXmlFull');
|
||||
const btnSignXmlDigest = document.getElementById('btnSignXmlDigest');
|
||||
const btnLoadXmlSample = document.getElementById('btnLoadXmlSample');
|
||||
const xmlAlert = document.getElementById('xmlAlert');
|
||||
const xmlSigOutput = document.getElementById('xmlSigOutput');
|
||||
const xmlTagOutput = document.getElementById('xmlTagOutput');
|
||||
|
||||
let currentXmlDigest = null;
|
||||
const btnDownloadXml = document.getElementById('btnDownloadXml');
|
||||
const btnCopyXml = document.getElementById('btnCopyXml');
|
||||
|
||||
const sampleXml = `<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">
|
||||
<NFe>
|
||||
@@ -1083,30 +1363,52 @@
|
||||
</NFe>
|
||||
</nfeProc>`;
|
||||
|
||||
async function updateXmlDigest() {
|
||||
const xml = xmlContentInput.value.trim();
|
||||
if (!xml) {
|
||||
currentXmlDigest = null;
|
||||
xmlDigestBox.textContent = 'Digite ou cole o XML para calcular o digest...';
|
||||
} else {
|
||||
currentXmlDigest = await signer._calculateSha256(xml);
|
||||
xmlDigestBox.textContent = `SHA-256 Digest (Base64): ${currentXmlDigest}\nTamanho: ${xml.length} caracteres`;
|
||||
}
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
xmlContentInput.addEventListener('input', updateXmlDigest);
|
||||
xmlContentInput.addEventListener('input', () => updateButtonStates());
|
||||
btnLoadXmlSample.addEventListener('click', () => {
|
||||
xmlContentInput.value = sampleXml;
|
||||
updateXmlDigest();
|
||||
updateButtonStates();
|
||||
});
|
||||
|
||||
btnSignXml.addEventListener('click', async () => {
|
||||
btnSignXml.disabled = true;
|
||||
btnSignXml.innerHTML = '<span class="spinner"></span> Solicitando no Agente...';
|
||||
// Assinatura Completa de XML
|
||||
btnSignXmlFull.addEventListener('click', async () => {
|
||||
btnSignXmlFull.disabled = true;
|
||||
btnSignXmlFull.innerHTML = '<span class="spinner"></span> Assinando no Agente...';
|
||||
xmlAlert.style.display = 'block';
|
||||
xmlAlert.className = 'alert alert-info';
|
||||
xmlAlert.textContent = 'Confirme a assinatura na janela do CyberCore Signer...';
|
||||
xmlAlert.textContent = 'Assinando XML no CyberCore Signer...';
|
||||
|
||||
try {
|
||||
const xml = xmlContentInput.value.trim();
|
||||
const profile = xmlProfileSelect.value;
|
||||
|
||||
const result = await signer.signXmlDocument({
|
||||
certificateId: selectedCertificateId,
|
||||
xml: xml,
|
||||
profile: profile
|
||||
});
|
||||
|
||||
xmlAlert.className = 'alert alert-success';
|
||||
xmlAlert.textContent = `✓ XML assinado com sucesso via perfil ${result.profile || profile}!`;
|
||||
currentSignedXml = result.signed_xml;
|
||||
xmlSigOutput.textContent = result.signed_xml;
|
||||
btnDownloadXml.style.display = 'inline-flex';
|
||||
btnCopyXml.style.display = 'inline-flex';
|
||||
} catch (err) {
|
||||
xmlAlert.className = 'alert alert-error';
|
||||
xmlAlert.textContent = 'Erro ao assinar XML: ' + err.message;
|
||||
} finally {
|
||||
btnSignXmlFull.disabled = false;
|
||||
btnSignXmlFull.innerHTML = '<span>✍️</span> Assinar XML Completo no Agente';
|
||||
}
|
||||
});
|
||||
|
||||
// Assinatura via Digest de XML
|
||||
btnSignXmlDigest.addEventListener('click', async () => {
|
||||
btnSignXmlDigest.disabled = true;
|
||||
btnSignXmlDigest.innerHTML = '<span class="spinner"></span> Solicitando...';
|
||||
xmlAlert.style.display = 'block';
|
||||
xmlAlert.className = 'alert alert-info';
|
||||
xmlAlert.textContent = 'Assinando digest do XML no CyberCore Signer...';
|
||||
|
||||
try {
|
||||
const result = await signer.signXml(xmlContentInput.value, {
|
||||
@@ -1115,33 +1417,40 @@
|
||||
});
|
||||
|
||||
xmlAlert.className = 'alert alert-success';
|
||||
xmlAlert.textContent = `✓ XML assinado com sucesso via ${result.algorithm}!`;
|
||||
xmlAlert.textContent = `✓ Digest XML assinado com sucesso via ${result.algorithm}!`;
|
||||
xmlSigOutput.textContent = result.signature;
|
||||
|
||||
const xmlSigSnippet = `<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
<SignedInfo>
|
||||
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
|
||||
<Reference URI="#NFe35261012345678000199550010000001001234567890">
|
||||
<DigestValue>${result.digest}</DigestValue>
|
||||
</Reference>
|
||||
</SignedInfo>
|
||||
<SignatureValue>${result.signature.slice(0, 48)}...</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509Certificate>${result.certificate.slice(0, 48)}...</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>`;
|
||||
xmlTagOutput.textContent = xmlSigSnippet;
|
||||
} catch (err) {
|
||||
xmlAlert.className = 'alert alert-error';
|
||||
xmlAlert.textContent = 'Erro ao assinar XML: ' + err.message;
|
||||
xmlAlert.textContent = 'Erro ao assinar digest do XML: ' + err.message;
|
||||
} finally {
|
||||
btnSignXml.disabled = false;
|
||||
btnSignXml.innerHTML = '<span>✍️</span> Assinar XML com CyberCore';
|
||||
btnSignXmlDigest.disabled = false;
|
||||
btnSignXmlDigest.innerHTML = '<span>🛡️</span> Apenas Digest';
|
||||
}
|
||||
});
|
||||
|
||||
// Download do XML Assinado
|
||||
btnDownloadXml.addEventListener('click', () => {
|
||||
if (!currentSignedXml) return;
|
||||
const blob = new Blob([currentSignedXml], { type: 'application/xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'documento_assinado.xml';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Copiar XML
|
||||
btnCopyXml.addEventListener('click', async () => {
|
||||
if (!currentSignedXml) return;
|
||||
await navigator.clipboard.writeText(currentSignedXml);
|
||||
const origText = btnCopyXml.innerHTML;
|
||||
btnCopyXml.innerHTML = '<span>✓</span> Copiado!';
|
||||
setTimeout(() => { btnCopyXml.innerHTML = origText; }, 2000);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// ABA 3: LÓGICA DE ARQUIVOS GERAIS
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
/**
|
||||
* CyberCore Signer - PdfSealDesigner
|
||||
* Ferramenta visual interativa para criação, personalização e exportação
|
||||
* de temas de selos de assinatura digital PAdES (ICP-Brasil).
|
||||
*/
|
||||
|
||||
export class PdfSealDesigner {
|
||||
/**
|
||||
* @param {Object} options Configurações iniciais do designer.
|
||||
* @param {HTMLElement|string} options.container Elemento onde renderizar o designer.
|
||||
* @param {Object} [options.initialTheme] Configuração inicial de tema.
|
||||
* @param {Function} [options.onChange] Callback chamado quando o tema for modificado: fn(theme).
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.container = typeof options.container === 'string'
|
||||
? document.querySelector(options.container)
|
||||
: options.container;
|
||||
|
||||
if (!this.container) {
|
||||
throw new Error('Container DOM obrigatório para instanciar PdfSealDesigner.');
|
||||
}
|
||||
|
||||
this.onChange = options.onChange || null;
|
||||
|
||||
// Estado do tema
|
||||
this.theme = {
|
||||
name: 'Meu Selo Personalizado',
|
||||
description: 'Selo visual corporativo customizado via CyberCore Web SDK',
|
||||
width: 320,
|
||||
height: 82,
|
||||
primaryColor: '#0d47a1',
|
||||
secondaryColor: '#0284c7',
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#93c5fd',
|
||||
textColor: '#0f172a',
|
||||
iconStyle: 'shield',
|
||||
customLogoBase64: null,
|
||||
customHeader: 'CYBERCORE • ICP-BRASIL',
|
||||
customFooter: 'Validade jurídica assegurada pela MP nº 2.200-2/2001',
|
||||
customFieldLabel: 'Cargo',
|
||||
customFieldValue: 'Diretor Responsável',
|
||||
defaultCpfMode: 'MASKED',
|
||||
defaultIncludeDateTime: true,
|
||||
...(options.initialTheme || {})
|
||||
};
|
||||
|
||||
this._initDom();
|
||||
this._attachEvents();
|
||||
this._renderPreview();
|
||||
}
|
||||
|
||||
_initDom() {
|
||||
this.container.innerHTML = `
|
||||
<div class="cyber-designer-root">
|
||||
<!-- Cabeçalho -->
|
||||
<div class="designer-header">
|
||||
<h3>🎨 Criador de Selo Visual (PAdES / ICP-Brasil)</h3>
|
||||
<p>Personalize cores, brasão, logotipo próprio e regras LGPD para a estampa no PDF.</p>
|
||||
</div>
|
||||
|
||||
<div class="designer-body">
|
||||
<!-- Coluna 1: Formulário de Configuração -->
|
||||
<div class="designer-form">
|
||||
<div class="form-row">
|
||||
<label>Nome do Selo:</label>
|
||||
<input type="text" class="input-theme-name" value="${this.theme.name}" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">Paleta de Cores</div>
|
||||
<div class="colors-grid">
|
||||
<div class="color-picker-item">
|
||||
<label>Primária:</label>
|
||||
<div class="color-input-wrap">
|
||||
<input type="color" class="color-primary" value="${this.theme.primaryColor}" />
|
||||
<span class="color-hex-text">${this.theme.primaryColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-picker-item">
|
||||
<label>Secundária:</label>
|
||||
<div class="color-input-wrap">
|
||||
<input type="color" class="color-secondary" value="${this.theme.secondaryColor}" />
|
||||
<span class="color-hex-text">${this.theme.secondaryColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-picker-item">
|
||||
<label>Fundo:</label>
|
||||
<div class="color-input-wrap">
|
||||
<input type="color" class="color-bg" value="${this.theme.backgroundColor}" />
|
||||
<span class="color-hex-text">${this.theme.backgroundColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-picker-item">
|
||||
<label>Borda:</label>
|
||||
<div class="color-input-wrap">
|
||||
<input type="color" class="color-border" value="${this.theme.borderColor}" />
|
||||
<span class="color-hex-text">${this.theme.borderColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-picker-item">
|
||||
<label>Texto:</label>
|
||||
<div class="color-input-wrap">
|
||||
<input type="color" class="color-text" value="${this.theme.textColor}" />
|
||||
<span class="color-hex-text">${this.theme.textColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">Ícone e Emblema</div>
|
||||
<div class="icon-selection-row">
|
||||
<label class="radio-icon-item">
|
||||
<input type="radio" name="iconStyle" value="shield" ${this.theme.iconStyle === 'shield' ? 'checked' : ''} />
|
||||
<span>🛡️ Escudo ICP</span>
|
||||
</label>
|
||||
<label class="radio-icon-item">
|
||||
<input type="radio" name="iconStyle" value="star" ${this.theme.iconStyle === 'star' ? 'checked' : ''} />
|
||||
<span>⭐ Gov.br</span>
|
||||
</label>
|
||||
<label class="radio-icon-item">
|
||||
<input type="radio" name="iconStyle" value="crystal" ${this.theme.iconStyle === 'crystal' ? 'checked' : ''} />
|
||||
<span>💎 Modern</span>
|
||||
</label>
|
||||
<label class="radio-icon-item">
|
||||
<input type="radio" name="iconStyle" value="notary" ${this.theme.iconStyle === 'notary' ? 'checked' : ''} />
|
||||
<span>📜 Notarial</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row logo-upload-row">
|
||||
<label>Logotipo Próprio (PNG/JPG):</label>
|
||||
<div class="logo-actions">
|
||||
<label class="btn-designer-upload">
|
||||
<span>📷</span> Escolher Imagem
|
||||
<input type="file" accept="image/png,image/jpeg,image/svg+xml" class="designer-logo-file" style="display:none;" />
|
||||
</label>
|
||||
<button type="button" class="btn-remove-logo" style="${this.theme.customLogoBase64 ? 'display:inline-block;' : 'display:none;'}">Remover</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">Identificação & LGPD</div>
|
||||
<div class="form-row-grid">
|
||||
<div class="form-row">
|
||||
<label>Rótulo Extra:</label>
|
||||
<input type="text" class="input-field-label" placeholder="Ex: Cargo ou Matrícula" value="${this.theme.customFieldLabel || ''}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Valor Extra:</label>
|
||||
<input type="text" class="input-field-value" placeholder="Ex: Auditor Fiscal" value="${this.theme.customFieldValue || ''}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row-grid">
|
||||
<div class="form-row">
|
||||
<label>Exibição do CPF:</label>
|
||||
<select class="select-cpf-mode">
|
||||
<option value="FULL" ${this.theme.defaultCpfMode === 'FULL' ? 'selected' : ''}>Completo (123.456.789-00)</option>
|
||||
<option value="MASKED" ${this.theme.defaultCpfMode === 'MASKED' ? 'selected' : ''}>Mascarado (***.456.789-**)</option>
|
||||
<option value="NONE" ${this.theme.defaultCpfMode === 'NONE' ? 'selected' : ''}>Ocultar CPF</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row checkbox-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" class="chk-date-time" ${this.theme.defaultIncludeDateTime ? 'checked' : ''} />
|
||||
<span>Incluir Data e Hora</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coluna 2: Live Preview e Exportação -->
|
||||
<div class="designer-preview-column">
|
||||
<div class="preview-card">
|
||||
<h4>👁️ Pré-visualização em Alta Resolução (320x82 pt)</h4>
|
||||
<div class="seal-live-preview-box">
|
||||
<!-- SVG Dinâmico -->
|
||||
</div>
|
||||
<small class="preview-note">Renderização visual idêntica à estampada no PDFBox do agente desktop.</small>
|
||||
</div>
|
||||
|
||||
<div class="json-export-card">
|
||||
<div class="json-export-header">
|
||||
<h4>📋 Objeto de Configuração (JSON)</h4>
|
||||
<button type="button" class="btn-copy-theme-json">Copiar JSON</button>
|
||||
</div>
|
||||
<pre class="theme-json-box"><code>{}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._injectStyles();
|
||||
|
||||
// Cache de elementos
|
||||
this.root = this.container.querySelector('.cyber-designer-root');
|
||||
this.previewBox = this.root.querySelector('.seal-live-preview-box');
|
||||
this.jsonBox = this.root.querySelector('.theme-json-box code');
|
||||
this.btnRemoveLogo = this.root.querySelector('.btn-remove-logo');
|
||||
}
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('cyber-designer-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'cyber-designer-styles';
|
||||
style.textContent = `
|
||||
.cyber-designer-root {
|
||||
background: #0f172a;
|
||||
border: 1px solid #24304f;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: #f8fafc;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
.designer-header {
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.designer-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #38bdf8;
|
||||
}
|
||||
.designer-header p {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.designer-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.designer-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.form-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #38bdf8;
|
||||
margin: 14px 0 8px 0;
|
||||
border-top: 1px solid #1e293b;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.form-row {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.form-row label {
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.form-row input[type="text"], .form-row select {
|
||||
background: #090d16;
|
||||
border: 1px solid #24304f;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
color: #f8fafc;
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-row input[type="text"]:focus, .form-row select:focus {
|
||||
border-color: #38bdf8;
|
||||
outline: none;
|
||||
}
|
||||
.form-row-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.colors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.color-picker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.color-picker-item label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.color-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #090d16;
|
||||
border: 1px solid #24304f;
|
||||
border-radius: 6px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.color-input-wrap input[type="color"] {
|
||||
border: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-hex-text {
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.icon-selection-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.radio-icon-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #090d16;
|
||||
border: 1px solid #24304f;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.logo-upload-row .logo-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-designer-upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #38bdf8;
|
||||
border-radius: 6px;
|
||||
color: #38bdf8;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-remove-logo {
|
||||
background: transparent;
|
||||
border: 1px solid #f87171;
|
||||
color: #f87171;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-row {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.designer-preview-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.preview-card, .json-export-card {
|
||||
background: #131c31;
|
||||
border: 1px solid #24304f;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.preview-card h4, .json-export-card h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.seal-live-preview-box {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
.seal-live-preview-box svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.preview-note {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.json-export-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.btn-copy-theme-json {
|
||||
background: #1e293b;
|
||||
border: 1px solid #24304f;
|
||||
color: #38bdf8;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-copy-theme-json:hover {
|
||||
background: #38bdf8;
|
||||
color: #0f172a;
|
||||
}
|
||||
.theme-json-box {
|
||||
background: #090d16;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10.5px;
|
||||
color: #38bdf8;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
_attachEvents() {
|
||||
const bindInput = (selector, key, formatFn = v => v) => {
|
||||
this.root.querySelector(selector).addEventListener('input', e => {
|
||||
this.theme[key] = formatFn(e.target.value);
|
||||
this._onStateChange();
|
||||
});
|
||||
};
|
||||
|
||||
bindInput('.input-theme-name', 'name');
|
||||
bindInput('.input-field-label', 'customFieldLabel');
|
||||
bindInput('.input-field-value', 'customFieldValue');
|
||||
|
||||
// Cores
|
||||
const bindColor = (selector, key, labelSelector) => {
|
||||
const el = this.root.querySelector(selector);
|
||||
const label = this.root.querySelector(labelSelector);
|
||||
el.addEventListener('input', e => {
|
||||
this.theme[key] = e.target.value;
|
||||
label.textContent = e.target.value;
|
||||
this._onStateChange();
|
||||
});
|
||||
};
|
||||
|
||||
bindColor('.color-primary', 'primaryColor', '.color-primary + .color-hex-text');
|
||||
bindColor('.color-secondary', 'secondaryColor', '.color-secondary + .color-hex-text');
|
||||
bindColor('.color-bg', 'backgroundColor', '.color-bg + .color-hex-text');
|
||||
bindColor('.color-border', 'borderColor', '.color-border + .color-hex-text');
|
||||
bindColor('.color-text', 'textColor', '.color-text + .color-hex-text');
|
||||
|
||||
// Ícones
|
||||
this.root.querySelectorAll('input[name="iconStyle"]').forEach(radio => {
|
||||
radio.addEventListener('change', e => {
|
||||
if (e.target.checked) {
|
||||
this.theme.iconStyle = e.target.value;
|
||||
this._onStateChange();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Upload de logo
|
||||
const logoInput = this.root.querySelector('.designer-logo-file');
|
||||
logoInput.addEventListener('change', async e => {
|
||||
if (e.target.files.length) {
|
||||
const file = e.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
this.theme.customLogoBase64 = ev.target.result;
|
||||
this.btnRemoveLogo.style.display = 'inline-block';
|
||||
this._onStateChange();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
this.btnRemoveLogo.addEventListener('click', () => {
|
||||
this.theme.customLogoBase64 = null;
|
||||
this.btnRemoveLogo.style.display = 'none';
|
||||
logoInput.value = '';
|
||||
this._onStateChange();
|
||||
});
|
||||
|
||||
// LGPD CPF
|
||||
this.root.querySelector('.select-cpf-mode').addEventListener('change', e => {
|
||||
this.theme.defaultCpfMode = e.target.value;
|
||||
this._onStateChange();
|
||||
});
|
||||
|
||||
// Data e hora
|
||||
this.root.querySelector('.chk-date-time').addEventListener('change', e => {
|
||||
this.theme.defaultIncludeDateTime = e.target.checked;
|
||||
this._onStateChange();
|
||||
});
|
||||
|
||||
// Copiar JSON
|
||||
this.root.querySelector('.btn-copy-theme-json').addEventListener('click', () => {
|
||||
const config = this.getThemeConfig();
|
||||
navigator.clipboard.writeText(JSON.stringify(config, null, 2));
|
||||
const btn = this.root.querySelector('.btn-copy-theme-json');
|
||||
btn.textContent = '✓ Copiado!';
|
||||
setTimeout(() => (btn.textContent = 'Copiar JSON'), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
_onStateChange() {
|
||||
this._renderPreview();
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getThemeConfig());
|
||||
}
|
||||
}
|
||||
|
||||
_renderPreview() {
|
||||
const t = this.theme;
|
||||
|
||||
// CPF conforme a escolha
|
||||
let docText = 'CPF: ***.456.789-**';
|
||||
if (t.defaultCpfMode === 'FULL') docText = 'CPF: 123.456.789-00';
|
||||
else if (t.defaultCpfMode === 'NONE') docText = '';
|
||||
|
||||
const dateText = t.defaultIncludeDateTime ? '11/10/2026 18:30:00' : '';
|
||||
let extraText = '';
|
||||
if (t.customFieldLabel || t.customFieldValue) {
|
||||
const label = t.customFieldLabel ? `${t.customFieldLabel}: ` : '';
|
||||
const val = t.customFieldValue || '';
|
||||
extraText = `${label}${val}`.trim();
|
||||
}
|
||||
|
||||
let iconGraphic = '';
|
||||
if (t.customLogoBase64) {
|
||||
iconGraphic = `<image href="${t.customLogoBase64}" x="12" y="14" width="34" height="34" preserveAspectRatio="xMidYMid meet" />`;
|
||||
} else {
|
||||
switch (t.iconStyle) {
|
||||
case 'star':
|
||||
iconGraphic = `
|
||||
<circle cx="28" cy="30" r="15" fill="${t.primaryColor}" opacity="0.12" />
|
||||
<circle cx="28" cy="30" r="15" fill="none" stroke="${t.primaryColor}" stroke-width="1.5" />
|
||||
<path d="M28 18 L31 26 L39 26 L33 31 L35 39 L28 34 L21 39 L23 31 L17 26 L25 26 Z" fill="${t.secondaryColor}" />
|
||||
`;
|
||||
break;
|
||||
case 'crystal':
|
||||
iconGraphic = `
|
||||
<polygon points="28,14 42,22 42,38 28,46 14,38 14,22" fill="${t.primaryColor}" opacity="0.15" />
|
||||
<polygon points="28,14 42,22 42,38 28,46 14,38 14,22" fill="none" stroke="${t.primaryColor}" stroke-width="1.5" />
|
||||
<line x1="28" y1="14" x2="28" y2="46" stroke="${t.secondaryColor}" stroke-width="1.5" />
|
||||
`;
|
||||
break;
|
||||
case 'notary':
|
||||
iconGraphic = `
|
||||
<circle cx="28" cy="30" r="16" fill="none" stroke="${t.primaryColor}" stroke-width="2" stroke-dasharray="3,2" />
|
||||
<circle cx="28" cy="30" r="12" fill="${t.primaryColor}" opacity="0.1" />
|
||||
<path d="M22 30 L26 34 L34 26" fill="none" stroke="${t.secondaryColor}" stroke-width="2" stroke-linecap="round" />
|
||||
`;
|
||||
break;
|
||||
case 'shield':
|
||||
default:
|
||||
iconGraphic = `
|
||||
<path d="M28 12 L40 17 V28 C40 38 33 46 28 49 C23 46 16 38 16 28 V17 Z" fill="${t.primaryColor}" opacity="0.12" />
|
||||
<path d="M28 12 L40 17 V28 C40 38 33 46 28 49 C23 46 16 38 16 28 V17 Z" fill="none" stroke="${t.primaryColor}" stroke-width="1.8" />
|
||||
<rect x="23" y="27" width="10" height="8" rx="2" fill="${t.secondaryColor}" />
|
||||
<path d="M25 27 V23.5 C25 21.8 26.3 20.5 28 20.5 C29.7 20.5 31 21.8 31 23.5 V27" fill="none" stroke="${t.secondaryColor}" stroke-width="1.5" />
|
||||
`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 82" width="320" height="82" style="border-radius:6px; background:${t.backgroundColor};">
|
||||
<rect x="1" y="1" width="318" height="80" rx="6" fill="${t.backgroundColor}" stroke="${t.borderColor}" stroke-width="1.5"/>
|
||||
<rect x="1" y="1" width="6" height="80" rx="3" fill="${t.primaryColor}"/>
|
||||
|
||||
<!-- Emblema / Brasão -->
|
||||
<g id="seal-icon-group">
|
||||
${iconGraphic}
|
||||
</g>
|
||||
|
||||
<!-- Tipografia do Selo -->
|
||||
<text x="54" y="19" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8.5" font-weight="bold" fill="${t.primaryColor}" letter-spacing="0.3">${t.customHeader || 'DOCUMENTO ASSINADO DIGITALMENTE'}</text>
|
||||
<text x="54" y="34" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="12" font-weight="bold" fill="${t.textColor}">MARIA DA SILVA</text>
|
||||
<text x="54" y="47" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${[docText, extraText].filter(Boolean).join(' • ')}</text>
|
||||
<text x="54" y="58" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="8" fill="#475569">${[dateText, 'AC SERPRO v5'].filter(Boolean).join(' • ')}</text>
|
||||
<text x="54" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">${t.customFooter || 'ICP-Brasil • MP nº 2.200-2/2001'}</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
this.previewBox.innerHTML = svg;
|
||||
this.jsonBox.textContent = JSON.stringify(this.getThemeConfig(), null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a configuração do tema pronto para uso no payload seal_custom de assinatura.
|
||||
*/
|
||||
getThemeConfig() {
|
||||
return {
|
||||
name: this.theme.name,
|
||||
description: this.theme.description,
|
||||
width: this.theme.width,
|
||||
height: this.theme.height,
|
||||
primaryColor: this.theme.primaryColor,
|
||||
secondaryColor: this.theme.secondaryColor,
|
||||
backgroundColor: this.theme.backgroundColor,
|
||||
borderColor: this.theme.borderColor,
|
||||
textColor: this.theme.textColor,
|
||||
iconStyle: this.theme.iconStyle,
|
||||
customLogoBase64: this.theme.customLogoBase64,
|
||||
customHeader: this.theme.customHeader,
|
||||
customFooter: this.theme.customFooter,
|
||||
customFieldLabel: this.theme.customFieldLabel,
|
||||
customFieldValue: this.theme.customFieldValue,
|
||||
defaultCpfMode: this.theme.defaultCpfMode,
|
||||
defaultIncludeDateTime: this.theme.defaultIncludeDateTime
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega um tema para edição.
|
||||
*/
|
||||
loadTheme(theme) {
|
||||
this.theme = { ...this.theme, ...theme };
|
||||
this._initDom();
|
||||
this._attachEvents();
|
||||
this._renderPreview();
|
||||
}
|
||||
}
|
||||
|
||||
export default PdfSealDesigner;
|
||||
@@ -0,0 +1,925 @@
|
||||
/**
|
||||
* CyberCore Signer - PdfStampPicker
|
||||
* Componente visual para pré-visualização de PDF, posicionamento interativo de selos
|
||||
* e geração do payload de assinatura em tempo real.
|
||||
*/
|
||||
|
||||
export class PdfStampPicker {
|
||||
/**
|
||||
* @param {Object} options Configurações do componente.
|
||||
* @param {HTMLElement|string} options.container Elemento DOM ou seletor onde renderizar o picker.
|
||||
* @param {File|Blob|ArrayBuffer|string} [options.pdf] Arquivo ou Base64 inicial do PDF.
|
||||
* @param {Object} [options.sealTheme] Configurações visuais do selo a estampar.
|
||||
* @param {number} [options.initialScale=1.0] Escala inicial do selo (ex: 1.0 = 100%).
|
||||
* @param {Function} [options.onPositionChange] Callback chamado a cada alteração de página ou coordenadas: fn(info).
|
||||
* @param {Function} [options.onSignRequest] Callback acionado ao clicar em "Assinar": fn(payload).
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.container = typeof options.container === 'string'
|
||||
? document.querySelector(options.container)
|
||||
: options.container;
|
||||
|
||||
if (!this.container) {
|
||||
throw new Error('Container DOM obrigatório para instanciar PdfStampPicker.');
|
||||
}
|
||||
|
||||
this.onPositionChange = options.onPositionChange || null;
|
||||
this.onSignRequest = options.onSignRequest || null;
|
||||
this.sealTheme = options.sealTheme || null;
|
||||
|
||||
// Estado interno
|
||||
this.pdfDocument = null;
|
||||
this.pdfBytes = null;
|
||||
this.pdfBase64 = null;
|
||||
this.documentName = 'documento.pdf';
|
||||
this.totalPages = 1;
|
||||
this.currentPageNumber = 1;
|
||||
this.zoom = 1.0;
|
||||
|
||||
// Dimensões do PDF na página ativa (em pontos tipográficos 72 DPI)
|
||||
this.pagePdfWidth = 595.28;
|
||||
this.pagePdfHeight = 841.89;
|
||||
|
||||
// Estado do selo
|
||||
this.sealScale = options.initialScale || 1.0;
|
||||
this.baseSealWidth = 230; // pixels base no visualizador
|
||||
this.baseSealHeight = 60;
|
||||
this.sealPosX = null; // pixel relativo ao container da folha
|
||||
this.sealPosY = null;
|
||||
this.isDragging = false;
|
||||
this.dragStartX = 0;
|
||||
this.dragStartY = 0;
|
||||
|
||||
// Coordenadas finais em pontos do PDF
|
||||
this.calculatedPdfX = 0;
|
||||
this.calculatedPdfY = 0;
|
||||
this.selectedPositionEnum = 'BOTTOM_RIGHT';
|
||||
|
||||
this._initDom();
|
||||
this._attachEvents();
|
||||
|
||||
if (options.pdf) {
|
||||
this.loadPdf(options.pdf);
|
||||
}
|
||||
}
|
||||
|
||||
_initDom() {
|
||||
this.container.innerHTML = `
|
||||
<div class="cyber-picker-root">
|
||||
<!-- Barra Superior de Ferramentas -->
|
||||
<div class="picker-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<label class="btn-picker-upload">
|
||||
<span>📄</span> Carregar PDF
|
||||
<input type="file" accept=".pdf,application/pdf" class="picker-file-input" style="display:none;" />
|
||||
</label>
|
||||
<span class="picker-doc-name" title="Nenhum arquivo carregado">Nenhum PDF selecionado</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group nav-group">
|
||||
<button type="button" class="btn-picker-tool btn-first-page" title="Primeira Página">⏮</button>
|
||||
<button type="button" class="btn-picker-tool btn-prev-page" title="Página Anterior">◀</button>
|
||||
<span class="page-counter">Pág. <b class="current-page-num">1</b> de <span class="total-pages-num">1</span></span>
|
||||
<button type="button" class="btn-picker-tool btn-next-page" title="Próxima Página">▶</button>
|
||||
<button type="button" class="btn-picker-tool btn-last-page" title="Última Página">⏭</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group zoom-group">
|
||||
<button type="button" class="btn-picker-tool btn-zoom-out" title="Diminuir Zoom">-</button>
|
||||
<span class="zoom-level-text">100%</span>
|
||||
<button type="button" class="btn-picker-tool btn-zoom-in" title="Aumentar Zoom">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Área Central Dividida: Folha Interativa + Painel de Payload -->
|
||||
<div class="picker-body">
|
||||
<!-- Visor da Folha do PDF -->
|
||||
<div class="picker-viewport">
|
||||
<div class="picker-sheet-wrapper">
|
||||
<div class="sheet-container">
|
||||
<canvas class="pdf-canvas"></canvas>
|
||||
<!-- Selo Visual Interativo (Drag & Drop) -->
|
||||
<div class="stamp-overlay" style="display:none;">
|
||||
<div class="stamp-content">
|
||||
<div class="stamp-icon-box">
|
||||
<svg viewBox="0 0 24 24" class="stamp-svg-icon" fill="currentColor">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-1 6h2v2h-2V7zm0 4h2v6h-2v-6z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stamp-info-box">
|
||||
<div class="stamp-badge-text">ASSINADO DIGITALMENTE • ICP-BRASIL</div>
|
||||
<div class="stamp-signer-name">NOME DO TITULAR DO CERTIFICADO</div>
|
||||
<div class="stamp-signer-meta">CPF: ***.456.789-** • VALIDADE JURÍDICA</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stamp-resize-handle" title="Arraste para redimensionar o selo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="picker-empty-state">
|
||||
<div class="empty-icon">📄</div>
|
||||
<p>Nenhum PDF carregado</p>
|
||||
<small>Arraste um PDF aqui ou clique em "Carregar PDF"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel Lateral: Controles & Inspetor de Payload em Tempo Real -->
|
||||
<div class="picker-sidebar">
|
||||
<div class="sidebar-section">
|
||||
<h3>📍 Posição do Selo</h3>
|
||||
<div class="preset-buttons">
|
||||
<button type="button" class="btn-preset active" data-pos="BOTTOM_RIGHT">Inferior Dir.</button>
|
||||
<button type="button" class="btn-preset" data-pos="BOTTOM_LEFT">Inferior Esq.</button>
|
||||
<button type="button" class="btn-preset" data-pos="TOP_RIGHT">Superior Dir.</button>
|
||||
<button type="button" class="btn-preset" data-pos="TOP_LEFT">Superior Esq.</button>
|
||||
</div>
|
||||
<small class="hint-text">Dica: Você também pode clicar ou arrastar o selo livremente sobre a folha.</small>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h3>📏 Tamanho do Selo</h3>
|
||||
<div class="scale-slider-row">
|
||||
<input type="range" class="seal-scale-range" min="0.4" max="1.8" step="0.05" value="1.0" />
|
||||
<span class="scale-val-label">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="coords-grid">
|
||||
<div class="coord-item">
|
||||
<span class="coord-label">Página:</span>
|
||||
<span class="coord-val val-page">1</span>
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="coord-label">Coordenada X:</span>
|
||||
<span class="coord-val val-x">0.0 pt</span>
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="coord-label">Coordenada Y:</span>
|
||||
<span class="coord-val val-y">0.0 pt</span>
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="coord-label">Escala:</span>
|
||||
<span class="coord-val val-scale">1.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloco de Código com o Payload Formatado -->
|
||||
<div class="sidebar-section flex-grow">
|
||||
<div class="payload-header">
|
||||
<h3>⚡ Payload da Requisição</h3>
|
||||
<button type="button" class="btn-copy-payload" title="Copiar JSON">Copiar</button>
|
||||
</div>
|
||||
<pre class="payload-code-box"><code>{}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button type="button" class="btn-trigger-sign">
|
||||
<span>✍️</span> Assinar com CyberCore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._injectStyles();
|
||||
|
||||
// Cache de elementos do DOM
|
||||
this.root = this.container.querySelector('.cyber-picker-root');
|
||||
this.fileInput = this.root.querySelector('.picker-file-input');
|
||||
this.docNameLabel = this.root.querySelector('.picker-doc-name');
|
||||
this.viewport = this.root.querySelector('.picker-viewport');
|
||||
this.sheetWrapper = this.root.querySelector('.picker-sheet-wrapper');
|
||||
this.sheetContainer = this.root.querySelector('.sheet-container');
|
||||
this.canvas = this.root.querySelector('.pdf-canvas');
|
||||
this.stampOverlay = this.root.querySelector('.stamp-overlay');
|
||||
this.emptyState = this.root.querySelector('.picker-empty-state');
|
||||
this.currentPageLabel = this.root.querySelector('.current-page-num');
|
||||
this.totalPagesLabel = this.root.querySelector('.total-pages-num');
|
||||
this.zoomLabel = this.root.querySelector('.zoom-level-text');
|
||||
this.scaleRange = this.root.querySelector('.seal-scale-range');
|
||||
this.scaleValLabel = this.root.querySelector('.scale-val-label');
|
||||
this.valPage = this.root.querySelector('.val-page');
|
||||
this.valX = this.root.querySelector('.val-x');
|
||||
this.valY = this.root.querySelector('.val-y');
|
||||
this.valScale = this.root.querySelector('.val-scale');
|
||||
this.payloadCode = this.root.querySelector('.payload-code-box code');
|
||||
this.btnCopyPayload = this.root.querySelector('.btn-copy-payload');
|
||||
this.btnTriggerSign = this.root.querySelector('.btn-trigger-sign');
|
||||
}
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('cyber-picker-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'cyber-picker-styles';
|
||||
style.textContent = `
|
||||
.cyber-picker-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #090d16;
|
||||
border: 1px solid #24304f;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
color: #f8fafc;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.picker-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #24304f;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-picker-upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #38bdf8;
|
||||
border-radius: 6px;
|
||||
color: #38bdf8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-picker-upload:hover {
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
.picker-doc-name {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
max-width: 180px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.btn-picker-tool {
|
||||
background: #18223c;
|
||||
border: 1px solid #24304f;
|
||||
color: #f8fafc;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-picker-tool:hover:not(:disabled) {
|
||||
background: #24304f;
|
||||
color: #38bdf8;
|
||||
}
|
||||
.btn-picker-tool:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-counter, .zoom-level-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.picker-body {
|
||||
display: flex;
|
||||
min-height: 520px;
|
||||
height: 600px;
|
||||
}
|
||||
.picker-viewport {
|
||||
flex: 1;
|
||||
background: #060911;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.picker-sheet-wrapper {
|
||||
display: none;
|
||||
box-shadow: 0 12px 35px rgba(0,0,0,0.65);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: #ffffff;
|
||||
}
|
||||
.sheet-container {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
}
|
||||
.pdf-canvas {
|
||||
display: block;
|
||||
max-width: none;
|
||||
}
|
||||
.stamp-overlay {
|
||||
position: absolute;
|
||||
width: 230px;
|
||||
height: 60px;
|
||||
border: 2px dashed #0284c7;
|
||||
background: rgba(13, 71, 161, 0.08);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.25);
|
||||
cursor: grab;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
.stamp-overlay:active {
|
||||
cursor: grabbing;
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 6px 20px rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
.stamp-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stamp-icon-box {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: #0d47a1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stamp-svg-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.stamp-info-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.stamp-badge-text {
|
||||
font-size: 7.5px;
|
||||
font-weight: 800;
|
||||
color: #0d47a1;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.stamp-signer-name {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin: 1px 0;
|
||||
}
|
||||
.stamp-signer-meta {
|
||||
font-size: 7.5px;
|
||||
color: #475569;
|
||||
}
|
||||
.picker-empty-state {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
.picker-empty-state .empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.picker-sidebar {
|
||||
width: 340px;
|
||||
background: #0f172a;
|
||||
border-left: 1px solid #24304f;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-section h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: #38bdf8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.preset-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-preset {
|
||||
background: #18223c;
|
||||
border: 1px solid #24304f;
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-preset:hover {
|
||||
background: #24304f;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.btn-preset.active {
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
border-color: #38bdf8;
|
||||
color: #38bdf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hint-text {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.scale-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.seal-scale-range {
|
||||
flex: 1;
|
||||
accent-color: #38bdf8;
|
||||
}
|
||||
.scale-val-label {
|
||||
font-size: 12px;
|
||||
color: #38bdf8;
|
||||
font-weight: 600;
|
||||
min-width: 40px;
|
||||
}
|
||||
.coords-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
background: #131c31;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #24304f;
|
||||
}
|
||||
.coord-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.coord-label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.coord-val {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
font-family: monospace;
|
||||
}
|
||||
.payload-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.btn-copy-payload {
|
||||
background: #1e293b;
|
||||
border: 1px solid #24304f;
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-copy-payload:hover {
|
||||
background: #38bdf8;
|
||||
color: #0f172a;
|
||||
}
|
||||
.payload-code-box {
|
||||
background: #090d16;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #34d399;
|
||||
overflow: auto;
|
||||
max-height: 160px;
|
||||
margin: 0;
|
||||
}
|
||||
.flex-grow {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.btn-trigger-sign {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #0284c7, #2563eb);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.35);
|
||||
}
|
||||
.btn-trigger-sign:hover {
|
||||
background: linear-gradient(135deg, #0ea5e9, #3b82f6);
|
||||
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.5);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
_attachEvents() {
|
||||
// Carregamento de arquivo
|
||||
this.fileInput.addEventListener('change', e => {
|
||||
if (e.target.files.length) {
|
||||
this.loadPdf(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop do arquivo PDF no viewport
|
||||
this.viewport.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
this.viewport.style.background = '#0d1527';
|
||||
});
|
||||
this.viewport.addEventListener('dragleave', () => {
|
||||
this.viewport.style.background = '#060911';
|
||||
});
|
||||
this.viewport.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
this.viewport.style.background = '#060911';
|
||||
if (e.dataTransfer.files.length) {
|
||||
this.loadPdf(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Navegação de páginas
|
||||
this.root.querySelector('.btn-first-page').addEventListener('click', () => this.goToPage(1));
|
||||
this.root.querySelector('.btn-prev-page').addEventListener('click', () => this.goToPage(this.currentPageNumber - 1));
|
||||
this.root.querySelector('.btn-next-page').addEventListener('click', () => this.goToPage(this.currentPageNumber + 1));
|
||||
this.root.querySelector('.btn-last-page').addEventListener('click', () => this.goToPage(this.totalPages));
|
||||
|
||||
// Zoom
|
||||
this.root.querySelector('.btn-zoom-in').addEventListener('click', () => this.setZoom(this.zoom + 0.15));
|
||||
this.root.querySelector('.btn-zoom-out').addEventListener('click', () => this.setZoom(this.zoom - 0.15));
|
||||
|
||||
// Escala do selo
|
||||
this.scaleRange.addEventListener('input', e => {
|
||||
this.setSealScale(parseFloat(e.target.value));
|
||||
});
|
||||
|
||||
// Presets de posição
|
||||
this.root.querySelectorAll('.btn-preset').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.root.querySelectorAll('.btn-preset').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
this.applyPositionPreset(btn.dataset.pos);
|
||||
});
|
||||
});
|
||||
|
||||
// Clique direto na folha para posicionar o selo
|
||||
this.sheetContainer.addEventListener('click', e => {
|
||||
if (this.isDragging) return;
|
||||
if (e.target.closest('.stamp-overlay')) return;
|
||||
const rect = this.sheetContainer.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const clickY = e.clientY - rect.top;
|
||||
const stampW = this.baseSealWidth * this.sealScale;
|
||||
const stampH = this.baseSealHeight * this.sealScale;
|
||||
this.moveStampTo(clickX - stampW / 2, clickY - stampH / 2);
|
||||
});
|
||||
|
||||
// Arraste do selo (mouse)
|
||||
this.stampOverlay.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
this.isDragging = true;
|
||||
const rect = this.stampOverlay.getBoundingClientRect();
|
||||
this.dragStartX = e.clientX - rect.left;
|
||||
this.dragStartY = e.clientY - rect.top;
|
||||
|
||||
const onMouseMove = moveEvent => {
|
||||
if (!this.isDragging) return;
|
||||
const containerRect = this.sheetContainer.getBoundingClientRect();
|
||||
const newX = moveEvent.clientX - containerRect.left - this.dragStartX;
|
||||
const newY = moveEvent.clientY - containerRect.top - this.dragStartY;
|
||||
this.moveStampTo(newX, newY);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
// Copiar Payload
|
||||
this.btnCopyPayload.addEventListener('click', () => {
|
||||
const payload = this.getPayload();
|
||||
navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
const oldText = this.btnCopyPayload.textContent;
|
||||
this.btnCopyPayload.textContent = '✓ Copiado!';
|
||||
this.btnCopyPayload.style.color = '#34d399';
|
||||
setTimeout(() => {
|
||||
this.btnCopyPayload.textContent = oldText;
|
||||
this.btnCopyPayload.style.color = '';
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
// Assinar
|
||||
this.btnTriggerSign.addEventListener('click', () => {
|
||||
if (this.onSignRequest) {
|
||||
this.onSignRequest(this.getPayload());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega um documento PDF para visualização e cálculo interativo.
|
||||
* @param {File|Blob|ArrayBuffer|string} source
|
||||
*/
|
||||
async loadPdf(source) {
|
||||
try {
|
||||
this.docNameLabel.textContent = 'Carregando documento...';
|
||||
|
||||
let arrayBuffer;
|
||||
if (typeof source === 'string') {
|
||||
// Base64
|
||||
const clean = source.includes(',') ? source.split(',')[1] : source;
|
||||
const binary = atob(clean);
|
||||
const len = binary.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
|
||||
arrayBuffer = bytes.buffer;
|
||||
this.pdfBase64 = clean;
|
||||
this.documentName = 'documento.pdf';
|
||||
} else if (source instanceof ArrayBuffer) {
|
||||
arrayBuffer = source;
|
||||
this.pdfBase64 = this._bufferToBase64(source);
|
||||
} else if (source instanceof Blob || (typeof File !== 'undefined' && source instanceof File)) {
|
||||
this.documentName = source.name || 'documento.pdf';
|
||||
arrayBuffer = await source.arrayBuffer();
|
||||
this.pdfBase64 = this._bufferToBase64(arrayBuffer);
|
||||
}
|
||||
|
||||
this.pdfBytes = new Uint8Array(arrayBuffer);
|
||||
this.docNameLabel.textContent = this.documentName;
|
||||
this.docNameLabel.title = this.documentName;
|
||||
|
||||
// Garante suporte ao PDF.js
|
||||
await this._ensurePdfJsLoaded();
|
||||
|
||||
const loadingTask = window.pdfjsLib.getDocument({ data: this.pdfBytes });
|
||||
this.pdfDocument = await loadingTask.promise;
|
||||
this.totalPages = this.pdfDocument.numPages;
|
||||
this.totalPagesLabel.textContent = this.totalPages;
|
||||
|
||||
this.emptyState.style.display = 'none';
|
||||
this.sheetWrapper.style.display = 'block';
|
||||
|
||||
// Posiciona na última página por padrão (recomendação ICP-Brasil PAdES)
|
||||
await this.goToPage(this.totalPages);
|
||||
|
||||
// Posiciona o selo no canto inferior direito inicial
|
||||
this.applyPositionPreset('BOTTOM_RIGHT');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro ao renderizar PDF no picker:', err);
|
||||
this.docNameLabel.textContent = 'Erro ao carregar PDF';
|
||||
alert('Não foi possível ler o arquivo PDF: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async _ensurePdfJsLoaded() {
|
||||
if (window.pdfjsLib) return;
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||||
script.onload = () => {
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error('Falha ao carregar a biblioteca de renderização de PDF.'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async goToPage(pageNum) {
|
||||
if (!this.pdfDocument) return;
|
||||
const pageTarget = Math.max(1, Math.min(pageNum, this.totalPages));
|
||||
this.currentPageNumber = pageTarget;
|
||||
this.currentPageLabel.textContent = pageTarget;
|
||||
|
||||
const page = await this.pdfDocument.getPage(pageTarget);
|
||||
const unscaledViewport = page.getViewport({ scale: 1.0 });
|
||||
this.pagePdfWidth = unscaledViewport.width;
|
||||
this.pagePdfHeight = unscaledViewport.height;
|
||||
|
||||
// Renderiza a folha no Canvas com o zoom atual
|
||||
const viewport = page.getViewport({ scale: this.zoom });
|
||||
this.canvas.width = viewport.width;
|
||||
this.canvas.height = viewport.height;
|
||||
this.sheetContainer.style.width = `${viewport.width}px`;
|
||||
this.sheetContainer.style.height = `${viewport.height}px`;
|
||||
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
|
||||
|
||||
this.stampOverlay.style.display = 'block';
|
||||
|
||||
// Recalcula limites se já houver posição definida
|
||||
if (this.sealPosX !== null && this.sealPosY !== null) {
|
||||
this.moveStampTo(this.sealPosX, this.sealPosY);
|
||||
} else {
|
||||
this.applyPositionPreset('BOTTOM_RIGHT');
|
||||
}
|
||||
}
|
||||
|
||||
setZoom(newZoom) {
|
||||
const clamped = Math.max(0.5, Math.min(newZoom, 2.0));
|
||||
this.zoom = Math.round(clamped * 100) / 100;
|
||||
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||||
if (this.pdfDocument) {
|
||||
this.goToPage(this.currentPageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
setSealScale(scale) {
|
||||
this.sealScale = scale;
|
||||
this.scaleValLabel.textContent = `${Math.round(scale * 100)}%`;
|
||||
this.valScale.textContent = `${scale.toFixed(2)}x`;
|
||||
|
||||
const stampW = this.baseSealWidth * this.sealScale;
|
||||
const stampH = this.baseSealHeight * this.sealScale;
|
||||
this.stampOverlay.style.width = `${stampW}px`;
|
||||
this.stampOverlay.style.height = `${stampH}px`;
|
||||
|
||||
if (this.sealPosX !== null && this.sealPosY !== null) {
|
||||
this.moveStampTo(this.sealPosX, this.sealPosY);
|
||||
}
|
||||
}
|
||||
|
||||
applyPositionPreset(preset) {
|
||||
this.selectedPositionEnum = preset;
|
||||
const sheetW = this.canvas.width;
|
||||
const sheetH = this.canvas.height;
|
||||
const stampW = this.baseSealWidth * this.sealScale;
|
||||
const stampH = this.baseSealHeight * this.sealScale;
|
||||
const margin = 20 * this.zoom;
|
||||
|
||||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
|
||||
switch (preset) {
|
||||
case 'BOTTOM_LEFT':
|
||||
targetX = margin;
|
||||
targetY = sheetH - stampH - margin;
|
||||
break;
|
||||
case 'TOP_RIGHT':
|
||||
targetX = sheetW - stampW - margin;
|
||||
targetY = margin;
|
||||
break;
|
||||
case 'TOP_LEFT':
|
||||
targetX = margin;
|
||||
targetY = margin;
|
||||
break;
|
||||
case 'BOTTOM_RIGHT':
|
||||
default:
|
||||
targetX = sheetW - stampW - margin;
|
||||
targetY = sheetH - stampH - margin;
|
||||
break;
|
||||
}
|
||||
|
||||
this.moveStampTo(targetX, targetY, preset);
|
||||
}
|
||||
|
||||
moveStampTo(x, y, presetName = 'CUSTOM') {
|
||||
const sheetW = this.canvas.width;
|
||||
const sheetH = this.canvas.height;
|
||||
const stampW = this.baseSealWidth * this.sealScale;
|
||||
const stampH = this.baseSealHeight * this.sealScale;
|
||||
|
||||
// Clamping para não ultrapassar as bordas da folha
|
||||
const clampedX = Math.max(0, Math.min(x, sheetW - stampW));
|
||||
const clampedY = Math.max(0, Math.min(y, sheetH - stampH));
|
||||
|
||||
this.sealPosX = clampedX;
|
||||
this.sealPosY = clampedY;
|
||||
this.stampOverlay.style.left = `${clampedX}px`;
|
||||
this.stampOverlay.style.top = `${clampedY}px`;
|
||||
|
||||
if (presetName !== 'CUSTOM') {
|
||||
this.selectedPositionEnum = presetName;
|
||||
} else {
|
||||
this.selectedPositionEnum = 'CUSTOM';
|
||||
this.root.querySelectorAll('.btn-preset').forEach(b => b.classList.remove('active'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Conversão Matemática para Pontos do PDF (Origem no Rodapé da Página)
|
||||
// =========================================================================
|
||||
const scaleRatioX = this.pagePdfWidth / sheetW;
|
||||
const scaleRatioY = this.pagePdfHeight / sheetH;
|
||||
|
||||
this.calculatedPdfX = Math.round((clampedX * scaleRatioX) * 10) / 10;
|
||||
// No PDF ISO 32000, o eixo Y = 0 é o canto inferior esquerdo:
|
||||
this.calculatedPdfY = Math.round(((sheetH - clampedY - stampH) * scaleRatioY) * 10) / 10;
|
||||
|
||||
this._updateUiFeedback();
|
||||
}
|
||||
|
||||
_updateUiFeedback() {
|
||||
this.valPage.textContent = this.currentPageNumber;
|
||||
this.valX.textContent = `${this.calculatedPdfX.toFixed(1)} pt`;
|
||||
this.valY.textContent = `${this.calculatedPdfY.toFixed(1)} pt`;
|
||||
this.valScale.textContent = `${this.sealScale.toFixed(2)}x`;
|
||||
|
||||
const payload = this.getPayload();
|
||||
this.payloadCode.textContent = JSON.stringify(payload, null, 2);
|
||||
|
||||
if (this.onPositionChange) {
|
||||
this.onPositionChange({
|
||||
page: this.currentPageNumber,
|
||||
x: this.calculatedPdfX,
|
||||
y: this.calculatedPdfY,
|
||||
scale: this.sealScale,
|
||||
position: this.selectedPositionEnum,
|
||||
pagePdfWidth: this.pagePdfWidth,
|
||||
pagePdfHeight: this.pagePdfHeight,
|
||||
payload: payload
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o payload estruturado pronto para a requisição POST /ccsp/v1/sign-pdf.
|
||||
*/
|
||||
getPayload() {
|
||||
return {
|
||||
version: '1',
|
||||
operation: 'sign_pdf',
|
||||
document_name: this.documentName,
|
||||
page: this.currentPageNumber,
|
||||
position: this.selectedPositionEnum,
|
||||
x: this.calculatedPdfX,
|
||||
y: this.calculatedPdfY,
|
||||
scale: Number(this.sealScale.toFixed(2)),
|
||||
visible_seal: true,
|
||||
seal_theme_id: this.sealTheme?.id || 'icp-brasil',
|
||||
seal_custom: this.sealTheme || null,
|
||||
reason: 'Assinatura Digital ICP-Brasil',
|
||||
location: 'Brasil',
|
||||
pdf: this.pdfBase64 ? `${this.pdfBase64.substring(0, 32)}...[${this.pdfBytes?.length || 0} bytes]` : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna os dados completos com o Base64 real para a execução de assinatura.
|
||||
*/
|
||||
getFullPayload(certificateId) {
|
||||
return {
|
||||
version: '1',
|
||||
operation: 'sign_pdf',
|
||||
certificate_hint: certificateId,
|
||||
document_name: this.documentName,
|
||||
page: this.currentPageNumber,
|
||||
position: this.selectedPositionEnum,
|
||||
x: this.calculatedPdfX,
|
||||
y: this.calculatedPdfY,
|
||||
scale: Number(this.sealScale.toFixed(2)),
|
||||
visible_seal: true,
|
||||
seal_theme_id: this.sealTheme?.id || 'icp-brasil',
|
||||
seal_custom: this.sealTheme || null,
|
||||
reason: 'Assinatura Digital ICP-Brasil',
|
||||
location: 'Brasil',
|
||||
pdf: this.pdfBase64
|
||||
};
|
||||
}
|
||||
|
||||
_bufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
export default PdfStampPicker;
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
ReplayDetectedError
|
||||
} from './errors.js';
|
||||
|
||||
import { PdfStampPicker } from './pdf-stamp-picker.js';
|
||||
import { PdfSealDesigner } from './pdf-seal-designer.js';
|
||||
|
||||
export { PdfStampPicker, PdfSealDesigner };
|
||||
|
||||
export class CyberCoreSigner {
|
||||
/**
|
||||
* Inicializa o cliente do SDK.
|
||||
@@ -284,6 +289,285 @@ export class CyberCoreSigner {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém a lista de temas de selos visuais disponíveis e o selo padrão ativo no agente.
|
||||
*
|
||||
* @returns {Promise<Object>} Objeto com a lista de selos e default_seal_id.
|
||||
*/
|
||||
async getSealThemes() {
|
||||
const resp = await this._fetch('/ccsp/v1/seals', { method: 'GET' });
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assina um documento PDF completo no padrão PAdES com carimbo/selo visual estilizado.
|
||||
* O PDF original pode ser fornecido como string Base64, File, Blob, ArrayBuffer ou Uint8Array.
|
||||
* O CyberCore Signer aplica a assinatura incremental com estampa gráfica e retorna o PDF assinado completo em Base64.
|
||||
*
|
||||
* @param {Object} params Parâmetros da assinatura do documento PDF.
|
||||
* @param {string|File|Blob|ArrayBuffer|Uint8Array} params.pdf PDF a ser assinado (Base64 ou arquivo binário).
|
||||
* @param {string} params.certificateId Identificador ou hint do certificado digital A1/A3.
|
||||
* @param {string} [params.documentName='documento.pdf'] Nome amigável do documento.
|
||||
* @param {number} [params.page] Número da página (1-indexado) onde o selo deve ser posicionado.
|
||||
* @param {string} [params.pageChoice='LAST_PAGE'] 'LAST_PAGE', 'FIRST_PAGE' ou null se page numérica for informada.
|
||||
* @param {string} [params.position='CUSTOM'] 'BOTTOM_RIGHT', 'BOTTOM_LEFT', 'TOP_RIGHT', 'TOP_LEFT', 'CUSTOM'.
|
||||
* @param {number} [params.x] Coordenada X em pontos tipográficos na página do PDF.
|
||||
* @param {number} [params.y] Coordenada Y em pontos tipográficos na página do PDF.
|
||||
* @param {number} [params.scale=1.0] Escala do selo (ex: 1.0 = 100%, 0.8 = 80%, 1.2 = 120%).
|
||||
* @param {boolean} [params.visibleSeal=true] Se false, a assinatura PAdES não inclui estampa gráfica visível.
|
||||
* @param {string} [params.sealThemeId] ID do tema do selo registrado no agente (ex: 'icp-brasil', 'gov-br').
|
||||
* @param {Object} [params.sealCustom] Objeto de tema customizado inline completo.
|
||||
* @param {string} [params.reason='Assinatura Digital ICP-Brasil'] Razão da assinatura.
|
||||
* @param {string} [params.location='Brasil'] Localidade da assinatura.
|
||||
* @param {string} [params.cpfDisplayMode] 'FULL', 'MASKED' ou 'NONE'.
|
||||
* @param {boolean} [params.includeDateTime] Se deve exibir data/hora no gráfico.
|
||||
* @param {string} [params.customExtraInfo] Texto extra como Cargo ou Matrícula.
|
||||
* @param {number} [params.expiresInSeconds=180] Tempo de validade da requisição em segundos.
|
||||
* @returns {Promise<Object>} Retorna o PDF assinado em Base64 e utilitários para download ou Blob.
|
||||
*/
|
||||
async signPdfDocument(params) {
|
||||
if (!params) {
|
||||
throw new CyberCoreError('Parâmetros de assinatura de PDF são obrigatórios.', 'BAD_REQUEST');
|
||||
}
|
||||
|
||||
const certHint = params.certificateId || params.certificateHint;
|
||||
if (!certHint) {
|
||||
throw new CertificateNotFoundError('É necessário especificar o certificado digital para assinar o PDF.');
|
||||
}
|
||||
|
||||
let pdfBase64;
|
||||
if (typeof params.pdf === 'string') {
|
||||
// Remove eventual prefixo data:application/pdf;base64,
|
||||
pdfBase64 = params.pdf.includes(',') ? params.pdf.split(',')[1] : params.pdf;
|
||||
} else if (params.pdf) {
|
||||
const buffer = await this._fileToBuffer(params.pdf);
|
||||
pdfBase64 = this._bufferToBase64(buffer);
|
||||
}
|
||||
|
||||
if (!pdfBase64) {
|
||||
throw new CyberCoreError('Nenhum documento PDF fornecido para assinatura.', 'BAD_REQUEST');
|
||||
}
|
||||
|
||||
const requestId = this._generateUuid();
|
||||
const nonce = this._generateUuid();
|
||||
const expiresAt = new Date(Date.now() + (params.expiresInSeconds || 180) * 1000).toISOString();
|
||||
|
||||
const payload = {
|
||||
version: '1',
|
||||
request_id: requestId,
|
||||
application_id: this.applicationId,
|
||||
operation: 'sign_pdf',
|
||||
pdf: pdfBase64,
|
||||
certificate_hint: certHint,
|
||||
document_name: params.documentName || params.fileName || (params.pdf?.name) || 'documento.pdf',
|
||||
page: params.page !== undefined ? Number(params.page) : null,
|
||||
page_choice: params.pageChoice || null,
|
||||
position: params.position || ((params.x !== undefined && params.y !== undefined) ? 'CUSTOM' : 'BOTTOM_RIGHT'),
|
||||
x: params.x !== undefined ? Number(params.x) : null,
|
||||
y: params.y !== undefined ? Number(params.y) : null,
|
||||
scale: params.scale !== undefined ? Number(params.scale) : 1.0,
|
||||
visible_seal: params.visibleSeal !== undefined ? Boolean(params.visibleSeal) : true,
|
||||
seal_theme_id: params.sealThemeId || params.sealTheme || null,
|
||||
seal_custom: params.sealCustom || null,
|
||||
reason: params.reason || 'Assinatura Digital ICP-Brasil',
|
||||
location: params.location || 'Brasil',
|
||||
cpf_display_mode: params.cpfDisplayMode || null,
|
||||
include_date_time: params.includeDateTime !== undefined ? Boolean(params.includeDateTime) : null,
|
||||
custom_extra_info: params.customExtraInfo || null,
|
||||
nonce: nonce,
|
||||
expires_at: expiresAt
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this._fetch('/ccsp/v1/sign-pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.status === 'signed') {
|
||||
const signedPdfBase64 = resp.signed_pdf;
|
||||
const self = this;
|
||||
|
||||
return {
|
||||
requestId: resp.request_id,
|
||||
status: resp.status,
|
||||
signedPdf: signedPdfBase64,
|
||||
sha256: resp.sha256,
|
||||
signature: resp.signature,
|
||||
certificate: resp.certificate,
|
||||
certificateChain: resp.certificate_chain || [resp.certificate],
|
||||
algorithm: resp.algorithm,
|
||||
subjectName: resp.subject_name || null,
|
||||
subjectCpfCnpj: resp.subject_cpf_cnpj || null,
|
||||
documentName: resp.document_name || payload.document_name,
|
||||
|
||||
/**
|
||||
* Converte o PDF assinado em Blob binário para upload via FormData ou visualização.
|
||||
* @returns {Blob}
|
||||
*/
|
||||
getSignedPdfBlob() {
|
||||
return self._base64ToBlob(signedPdfBase64, 'application/pdf');
|
||||
},
|
||||
|
||||
/**
|
||||
* Retorna os bytes puros do PDF assinado em Uint8Array.
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
getSignedPdfBytes() {
|
||||
return self._base64ToBytes(signedPdfBase64);
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispara o download automático do PDF assinado no navegador web.
|
||||
* @param {string} [filename] Nome do arquivo para download.
|
||||
*/
|
||||
download(filename) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const blob = self._base64ToBlob(signedPdfBase64, 'application/pdf');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || resp.document_name || 'documento_assinado.pdf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this._throwMappedError(resp.error_code, resp.message);
|
||||
} catch (err) {
|
||||
if (err instanceof CyberCoreError) throw err;
|
||||
throw new CyberCoreError('Erro durante a assinatura do documento PDF: ' + err.message, 'SIGN_PDF_ERROR', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assina um documento XML completo no padrão XAdES-BES (ICP-Brasil) ou W3C XMLDSig Enveloped.
|
||||
* O XML pode ser fornecido como string de texto puro ou arquivo/bytes.
|
||||
*
|
||||
* @param {Object} params Parâmetros da assinatura XML.
|
||||
* @param {string|File|Blob|ArrayBuffer} params.xml Conteúdo do XML a ser assinado.
|
||||
* @param {string} params.certificateId Identificador do certificado digital.
|
||||
* @param {string} [params.profile='XADES_BES'] Perfil de assinatura: 'XADES_BES' ou 'XMLDSIG'.
|
||||
* @param {string} [params.targetElementTag] Nome da tag alvo (ex: 'infNFe', 'infCte') ou null para o documento todo.
|
||||
* @param {string} [params.targetElementId] ID do elemento alvo (ex: 'NFe3526...').
|
||||
* @param {string} [params.documentName='documento.xml'] Nome amigável do arquivo XML.
|
||||
* @param {string} [params.algorithm] Algoritmo XMLDSig.
|
||||
* @param {number} [params.expiresInSeconds=180] Tempo de validade da requisição em segundos.
|
||||
* @returns {Promise<Object>} Retorna o XML assinado como string de texto e helpers para Blob.
|
||||
*/
|
||||
async signXmlDocument(params) {
|
||||
if (!params) {
|
||||
throw new CyberCoreError('Parâmetros de assinatura XML são obrigatórios.', 'BAD_REQUEST');
|
||||
}
|
||||
|
||||
const certHint = params.certificateId || params.certificateHint;
|
||||
if (!certHint) {
|
||||
throw new CertificateNotFoundError('É necessário especificar o certificado digital para assinar o XML.');
|
||||
}
|
||||
|
||||
let xmlText = null;
|
||||
let xmlBase64 = null;
|
||||
|
||||
if (typeof params.xml === 'string') {
|
||||
xmlText = params.xml;
|
||||
} else if (params.xml) {
|
||||
const buffer = await this._fileToBuffer(params.xml);
|
||||
xmlBase64 = this._bufferToBase64(buffer);
|
||||
}
|
||||
|
||||
if (!xmlText && !xmlBase64) {
|
||||
throw new CyberCoreError('Nenhum documento XML fornecido para assinatura.', 'BAD_REQUEST');
|
||||
}
|
||||
|
||||
const requestId = this._generateUuid();
|
||||
const nonce = this._generateUuid();
|
||||
const expiresAt = new Date(Date.now() + (params.expiresInSeconds || 180) * 1000).toISOString();
|
||||
|
||||
const payload = {
|
||||
version: '1',
|
||||
request_id: requestId,
|
||||
application_id: this.applicationId,
|
||||
operation: 'sign_xml',
|
||||
xml: xmlText,
|
||||
xml_base64: xmlBase64,
|
||||
certificate_hint: certHint,
|
||||
document_name: params.documentName || params.fileName || (params.xml?.name) || 'documento.xml',
|
||||
profile: params.profile || 'XADES_BES',
|
||||
target_element_tag: params.targetElementTag || null,
|
||||
target_element_id: params.targetElementId || null,
|
||||
algorithm: params.algorithm || null,
|
||||
nonce: nonce,
|
||||
expires_at: expiresAt
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this._fetch('/ccsp/v1/sign-xml', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.status === 'signed') {
|
||||
const signedXmlText = resp.signed_xml;
|
||||
const self = this;
|
||||
|
||||
return {
|
||||
requestId: resp.request_id,
|
||||
status: resp.status,
|
||||
signedXml: signedXmlText,
|
||||
sha256: resp.sha256,
|
||||
signature: resp.signature,
|
||||
certificate: resp.certificate,
|
||||
certificateChain: resp.certificate_chain || [resp.certificate],
|
||||
algorithm: resp.algorithm,
|
||||
subjectName: resp.subject_name || null,
|
||||
subjectCpfCnpj: resp.subject_cpf_cnpj || null,
|
||||
documentName: resp.document_name || payload.document_name,
|
||||
|
||||
/**
|
||||
* Converte o XML assinado em Blob binário para upload.
|
||||
* @returns {Blob}
|
||||
*/
|
||||
getSignedXmlBlob() {
|
||||
return new Blob([signedXmlText], { type: 'application/xml;charset=utf-8' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Retorna os bytes UTF-8 do XML assinado em Uint8Array.
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
getSignedXmlBytes() {
|
||||
return new TextEncoder().encode(signedXmlText);
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispara o download automático do XML assinado no navegador web.
|
||||
* @param {string} [filename]
|
||||
*/
|
||||
download(filename) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const blob = new Blob([signedXmlText], { type: 'application/xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || resp.document_name || 'documento_assinado.xml';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this._throwMappedError(resp.error_code, resp.message);
|
||||
} catch (err) {
|
||||
if (err instanceof CyberCoreError) throw err;
|
||||
throw new CyberCoreError('Erro durante a assinatura do documento XML: ' + err.message, 'SIGN_XML_ERROR', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Realiza login / autenticação segura em sistemas web utilizando Certificado Digital (Challenge-Response ICP-Brasil).
|
||||
* Assina o desafio (challenge) emitido pelo servidor e retorna o certificado e dados decodificados do titular.
|
||||
@@ -421,6 +705,38 @@ export class CyberCoreSigner {
|
||||
throw new Error('Tipo de arquivo não suportado para leitura binária.');
|
||||
}
|
||||
|
||||
_bufferToBase64(buffer) {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(buffer).toString('base64');
|
||||
}
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
_base64ToBytes(base64) {
|
||||
const clean = base64.includes(',') ? base64.split(',')[1] : base64;
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return new Uint8Array(Buffer.from(clean, 'base64'));
|
||||
}
|
||||
const binary = atob(clean);
|
||||
const len = binary.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
_base64ToBlob(base64, mimeType = 'application/octet-stream') {
|
||||
const bytes = this._base64ToBytes(base64);
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
async _calculateSha256(data) {
|
||||
let buffer;
|
||||
if (typeof data === 'string') {
|
||||
@@ -492,6 +808,8 @@ export class CyberCoreSigner {
|
||||
// Expõe globalmente caso seja carregado diretamente via tag <script>
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CyberCoreSigner = CyberCoreSigner;
|
||||
window.PdfStampPicker = PdfStampPicker;
|
||||
window.PdfSealDesigner = PdfSealDesigner;
|
||||
}
|
||||
|
||||
export default CyberCoreSigner;
|
||||
|
||||
@@ -250,4 +250,131 @@ describe('@cybercore/signer-sdk - Testes Unitários do SDK', () => {
|
||||
assert.equal(res.subjectCpfCnpj, '98765432100');
|
||||
assert.equal(capturedPayload.purpose, 'AUTHENTICATION');
|
||||
});
|
||||
|
||||
it('deve consultar lista de temas de selos com getSealThemes()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
assert.equal(endpoint, '/ccsp/v1/seals');
|
||||
return {
|
||||
version: '1',
|
||||
status: 'success',
|
||||
default_seal_id: 'icp-brasil',
|
||||
seals: [
|
||||
{ id: 'icp-brasil', name: 'ICP-Brasil Oficial' },
|
||||
{ id: 'gov-br', name: 'Gov.br Oficial' }
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const res = await signer.getSealThemes();
|
||||
assert.equal(res.status, 'success');
|
||||
assert.equal(res.default_seal_id, 'icp-brasil');
|
||||
assert.equal(res.seals.length, 2);
|
||||
});
|
||||
|
||||
it('deve assinar documento PDF completo em Base64 com signPdfDocument()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
let capturedPayload = null;
|
||||
let calledEndpoint = null;
|
||||
const fakeSignedPdfB64 = Buffer.from('%PDF-1.4 Fake Signed PDF Content').toString('base64');
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
calledEndpoint = endpoint;
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return {
|
||||
version: '1',
|
||||
request_id: capturedPayload.request_id,
|
||||
status: 'signed',
|
||||
signed_pdf: fakeSignedPdfB64,
|
||||
sha256: 'FAKESHA256HASH12345',
|
||||
certificate: 'FAKECERTBASE64',
|
||||
certificate_chain: ['FAKECERTBASE64'],
|
||||
algorithm: 'SHA256withRSA',
|
||||
subject_name: 'MARIA DA SILVA',
|
||||
subject_cpf_cnpj: '12345678900',
|
||||
document_name: capturedPayload.document_name
|
||||
};
|
||||
};
|
||||
|
||||
const rawPdfBuffer = Buffer.from('%PDF-1.4 Document Original');
|
||||
const res = await signer.signPdfDocument({
|
||||
pdf: rawPdfBuffer,
|
||||
certificateId: 'cert-a3-win',
|
||||
documentName: 'contrato.pdf',
|
||||
page: 2,
|
||||
x: 350.5,
|
||||
y: 50.0,
|
||||
scale: 1.1,
|
||||
sealThemeId: 'gov-br',
|
||||
customExtraInfo: 'Matrícula: 1048'
|
||||
});
|
||||
|
||||
assert.equal(calledEndpoint, '/ccsp/v1/sign-pdf');
|
||||
assert.equal(res.status, 'signed');
|
||||
assert.equal(res.signedPdf, fakeSignedPdfB64);
|
||||
assert.equal(res.sha256, 'FAKESHA256HASH12345');
|
||||
assert.equal(res.subjectName, 'MARIA DA SILVA');
|
||||
assert.equal(res.subjectCpfCnpj, '12345678900');
|
||||
assert.equal(capturedPayload.page, 2);
|
||||
assert.equal(capturedPayload.x, 350.5);
|
||||
assert.equal(capturedPayload.y, 50.0);
|
||||
assert.equal(capturedPayload.scale, 1.1);
|
||||
assert.equal(capturedPayload.seal_theme_id, 'gov-br');
|
||||
assert.equal(capturedPayload.custom_extra_info, 'Matrícula: 1048');
|
||||
|
||||
// Valida método helper de bytes
|
||||
const signedBytes = res.getSignedPdfBytes();
|
||||
assert.ok(signedBytes instanceof Uint8Array);
|
||||
assert.ok(signedBytes.length > 0);
|
||||
});
|
||||
|
||||
it('deve assinar documento XML completo com signXmlDocument()', async () => {
|
||||
const signer = new CyberCoreSigner();
|
||||
|
||||
let capturedPayload = null;
|
||||
let calledEndpoint = null;
|
||||
const fakeSignedXml = '<nfeProc><NFe>...</NFe><ds:Signature>...</ds:Signature></nfeProc>';
|
||||
|
||||
signer._fetch = async (endpoint, options) => {
|
||||
calledEndpoint = endpoint;
|
||||
capturedPayload = JSON.parse(options.body);
|
||||
return {
|
||||
version: '1',
|
||||
request_id: capturedPayload.request_id,
|
||||
status: 'signed',
|
||||
signed_xml: fakeSignedXml,
|
||||
sha256: 'XMLSHA256HASH',
|
||||
certificate: 'FAKECERTBASE64',
|
||||
certificate_chain: ['FAKECERTBASE64'],
|
||||
algorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
|
||||
subject_name: 'CYBERCORE LTDA',
|
||||
subject_cpf_cnpj: '12345678000199',
|
||||
document_name: capturedPayload.document_name
|
||||
};
|
||||
};
|
||||
|
||||
const xmlInput = '<nfeProc><NFe Id="NFe123">Dados</NFe></nfeProc>';
|
||||
const res = await signer.signXmlDocument({
|
||||
xml: xmlInput,
|
||||
certificateId: 'cert-a1-pj',
|
||||
documentName: 'nfe-123.xml',
|
||||
profile: 'XADES_BES',
|
||||
targetElementTag: 'NFe',
|
||||
targetElementId: 'NFe123'
|
||||
});
|
||||
|
||||
assert.equal(calledEndpoint, '/ccsp/v1/sign-xml');
|
||||
assert.equal(res.status, 'signed');
|
||||
assert.equal(res.signedXml, fakeSignedXml);
|
||||
assert.equal(res.subjectName, 'CYBERCORE LTDA');
|
||||
assert.equal(capturedPayload.profile, 'XADES_BES');
|
||||
assert.equal(capturedPayload.target_element_tag, 'NFe');
|
||||
assert.equal(capturedPayload.target_element_id, 'NFe123');
|
||||
|
||||
// Helper de bytes
|
||||
const xmlBytes = res.getSignedXmlBytes();
|
||||
assert.ok(xmlBytes instanceof Uint8Array);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user