Files
cybercore-signer/sdk/src/pdf-seal-designer.js
T

726 lines
26 KiB
JavaScript

/**
* CyberCore Signer - PdfSealDesigner
* Ferramenta visual interativa para criação, personalização e exportação
* de temas de selos de assinatura digital PAdES (ICP-Brasil).
*/
function escapeSvgText(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function sanitizeLogoUrl(url) {
if (!url || typeof url !== 'string') return '';
const trimmed = url.trim();
if (/^data:image\/(png|jpeg|jpg|svg\+xml);base64,[A-Za-z0-9+/=]+$/i.test(trimmed)) {
return trimmed;
}
return '';
}
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 || options.onConfigChange || null;
const rawInitial = options.initialTheme || options.initialConfig || {};
const normalizedInitial = {
name: rawInitial.name,
description: rawInitial.description,
width: rawInitial.width,
height: rawInitial.height,
primaryColor: rawInitial.primaryColor || rawInitial.primary_color,
secondaryColor: rawInitial.secondaryColor || rawInitial.secondary_color,
backgroundColor: rawInitial.backgroundColor || rawInitial.background_color,
borderColor: rawInitial.borderColor || rawInitial.border_color,
textColor: rawInitial.textColor || rawInitial.text_color,
iconStyle: rawInitial.iconStyle || rawInitial.icon_style,
customLogoBase64: rawInitial.customLogoBase64 || rawInitial.custom_logo_base64,
customHeader: rawInitial.customHeader || rawInitial.custom_header || rawInitial.title,
customFooter: rawInitial.customFooter || rawInitial.custom_footer,
customFieldLabel: rawInitial.customFieldLabel || rawInitial.custom_field_label,
customFieldValue: rawInitial.customFieldValue || rawInitial.custom_field_value,
defaultCpfMode: rawInitial.defaultCpfMode || rawInitial.default_cpf_mode,
defaultIncludeDateTime: rawInitial.defaultIncludeDateTime !== undefined ? rawInitial.defaultIncludeDateTime : rawInitial.default_include_date_time
};
// Remove chaves indefinidas para não sobrescrever os defaults
Object.keys(normalizedInitial).forEach(k => normalizedInitial[k] === undefined && delete normalizedInitial[k]);
// 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: 'DOCUMENTO ASSINADO DIGITALMENTE',
customFooter: 'Verifique a autenticidade da assinatura eletrônica em https://validar.iti.gov.br',
customFieldLabel: '',
customFieldValue: '',
defaultCpfMode: 'MASKED',
defaultIncludeDateTime: true,
...normalizedInitial
};
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;
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.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: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
min-width: 0;
}
@media (max-width: 860px) {
.designer-body {
grid-template-columns: minmax(0, 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;
min-width: 0;
max-width: 100%;
}
.preview-card, .json-export-card {
background: #131c31;
border: 1px solid #24304f;
border-radius: 10px;
padding: 16px;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.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);
max-width: 340px;
margin: 0 auto;
box-sizing: border-box;
overflow: hidden;
}
.seal-live-preview-box svg {
max-width: 100%;
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-y: auto;
overflow-x: hidden;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
word-wrap: break-word;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
.theme-json-box code {
white-space: pre-wrap;
word-break: break-all;
word-wrap: break-word;
display: block;
max-width: 100%;
}
`;
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 = '';
const safeLogo = sanitizeLogoUrl(t.customLogoBase64);
if (safeLogo) {
iconGraphic = `<image xlink:href="${safeLogo}" href="${safeLogo}" x="12" y="14" width="34" height="54" 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" xmlns:xlink="http://www.w3.org/1999/xlink" 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="${escapeSvgText(t.primaryColor)}" letter-spacing="0.3">${escapeSvgText(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="${escapeSvgText(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">${escapeSvgText([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">${escapeSvgText(dateText ? `Data: ${dateText}` : '')}</text>
<text x="54" y="70" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="7.5" fill="#64748b">${escapeSvgText(t.customFooter || 'ICP-Brasil • MP nº 2.200-2/2001')}</text>
</svg>
`;
this.previewBox.innerHTML = svg;
// Na visualização do código, trunca se o Base64 for gigante para preservar performance da UI
const displayConfig = { ...this.getThemeConfig() };
if (displayConfig.customLogoBase64 && displayConfig.customLogoBase64.length > 100) {
const lenKb = (displayConfig.customLogoBase64.length / 1024).toFixed(1);
displayConfig.customLogoBase64 = `${displayConfig.customLogoBase64.substring(0, 32)}... [${lenKb} KB]`;
}
this.jsonBox.textContent = JSON.stringify(displayConfig, 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
};
}
/**
* Alias conveniente para getThemeConfig().
*/
getConfig() {
return this.getThemeConfig();
}
/**
* Carrega um tema para edição.
*/
loadTheme(theme) {
this.theme = { ...this.theme, ...theme };
this._initDom();
this._attachEvents();
this._renderPreview();
}
}
export default PdfSealDesigner;