/** * 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 = `
Nenhum PDF selecionado
100%
📄

Nenhum PDF carregado

Arraste um PDF aqui ou clique em "Carregar PDF"
`; 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;