feat: add SDK support for PDF document signing and visual seal customization tools
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user