feat: add SDK support for PDF document signing and visual seal customization tools

This commit is contained in:
Claudecio Martins
2026-10-11 18:34:47 -03:00
parent d8d2e6ee46
commit b46d8b665f
12 changed files with 3497 additions and 121 deletions
+925
View File
@@ -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;