Files
cybercore-signer/sdk/src/pdf-stamp-picker.js
T

926 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;