feat: add SDK support for PDF document signing and visual seal customization tools
This commit is contained in:
@@ -16,6 +16,11 @@ import {
|
||||
ReplayDetectedError
|
||||
} from './errors.js';
|
||||
|
||||
import { PdfStampPicker } from './pdf-stamp-picker.js';
|
||||
import { PdfSealDesigner } from './pdf-seal-designer.js';
|
||||
|
||||
export { PdfStampPicker, PdfSealDesigner };
|
||||
|
||||
export class CyberCoreSigner {
|
||||
/**
|
||||
* Inicializa o cliente do SDK.
|
||||
@@ -284,6 +289,285 @@ export class CyberCoreSigner {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém a lista de temas de selos visuais disponíveis e o selo padrão ativo no agente.
|
||||
*
|
||||
* @returns {Promise<Object>} Objeto com a lista de selos e default_seal_id.
|
||||
*/
|
||||
async getSealThemes() {
|
||||
const resp = await this._fetch('/ccsp/v1/seals', { method: 'GET' });
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assina um documento PDF completo no padrão PAdES com carimbo/selo visual estilizado.
|
||||
* O PDF original pode ser fornecido como string Base64, File, Blob, ArrayBuffer ou Uint8Array.
|
||||
* O CyberCore Signer aplica a assinatura incremental com estampa gráfica e retorna o PDF assinado completo em Base64.
|
||||
*
|
||||
* @param {Object} params Parâmetros da assinatura do documento PDF.
|
||||
* @param {string|File|Blob|ArrayBuffer|Uint8Array} params.pdf PDF a ser assinado (Base64 ou arquivo binário).
|
||||
* @param {string} params.certificateId Identificador ou hint do certificado digital A1/A3.
|
||||
* @param {string} [params.documentName='documento.pdf'] Nome amigável do documento.
|
||||
* @param {number} [params.page] Número da página (1-indexado) onde o selo deve ser posicionado.
|
||||
* @param {string} [params.pageChoice='LAST_PAGE'] 'LAST_PAGE', 'FIRST_PAGE' ou null se page numérica for informada.
|
||||
* @param {string} [params.position='CUSTOM'] 'BOTTOM_RIGHT', 'BOTTOM_LEFT', 'TOP_RIGHT', 'TOP_LEFT', 'CUSTOM'.
|
||||
* @param {number} [params.x] Coordenada X em pontos tipográficos na página do PDF.
|
||||
* @param {number} [params.y] Coordenada Y em pontos tipográficos na página do PDF.
|
||||
* @param {number} [params.scale=1.0] Escala do selo (ex: 1.0 = 100%, 0.8 = 80%, 1.2 = 120%).
|
||||
* @param {boolean} [params.visibleSeal=true] Se false, a assinatura PAdES não inclui estampa gráfica visível.
|
||||
* @param {string} [params.sealThemeId] ID do tema do selo registrado no agente (ex: 'icp-brasil', 'gov-br').
|
||||
* @param {Object} [params.sealCustom] Objeto de tema customizado inline completo.
|
||||
* @param {string} [params.reason='Assinatura Digital ICP-Brasil'] Razão da assinatura.
|
||||
* @param {string} [params.location='Brasil'] Localidade da assinatura.
|
||||
* @param {string} [params.cpfDisplayMode] 'FULL', 'MASKED' ou 'NONE'.
|
||||
* @param {boolean} [params.includeDateTime] Se deve exibir data/hora no gráfico.
|
||||
* @param {string} [params.customExtraInfo] Texto extra como Cargo ou Matrícula.
|
||||
* @param {number} [params.expiresInSeconds=180] Tempo de validade da requisição em segundos.
|
||||
* @returns {Promise<Object>} Retorna o PDF assinado em Base64 e utilitários para download ou Blob.
|
||||
*/
|
||||
async signPdfDocument(params) {
|
||||
if (!params) {
|
||||
throw new CyberCoreError('Parâmetros de assinatura de PDF são obrigatórios.', 'BAD_REQUEST');
|
||||
}
|
||||
|
||||
const certHint = params.certificateId || params.certificateHint;
|
||||
if (!certHint) {
|
||||
throw new CertificateNotFoundError('É necessário especificar o certificado digital para assinar o PDF.');
|
||||
}
|
||||
|
||||
let pdfBase64;
|
||||
if (typeof params.pdf === 'string') {
|
||||
// Remove eventual prefixo data:application/pdf;base64,
|
||||
pdfBase64 = params.pdf.includes(',') ? params.pdf.split(',')[1] : params.pdf;
|
||||
} else if (params.pdf) {
|
||||
const buffer = await this._fileToBuffer(params.pdf);
|
||||
pdfBase64 = this._bufferToBase64(buffer);
|
||||
}
|
||||
|
||||
if (!pdfBase64) {
|
||||
throw new CyberCoreError('Nenhum documento PDF fornecido para assinatura.', 'BAD_REQUEST');
|
||||
}
|
||||
|
||||
const requestId = this._generateUuid();
|
||||
const nonce = this._generateUuid();
|
||||
const expiresAt = new Date(Date.now() + (params.expiresInSeconds || 180) * 1000).toISOString();
|
||||
|
||||
const payload = {
|
||||
version: '1',
|
||||
request_id: requestId,
|
||||
application_id: this.applicationId,
|
||||
operation: 'sign_pdf',
|
||||
pdf: pdfBase64,
|
||||
certificate_hint: certHint,
|
||||
document_name: params.documentName || params.fileName || (params.pdf?.name) || 'documento.pdf',
|
||||
page: params.page !== undefined ? Number(params.page) : null,
|
||||
page_choice: params.pageChoice || null,
|
||||
position: params.position || ((params.x !== undefined && params.y !== undefined) ? 'CUSTOM' : 'BOTTOM_RIGHT'),
|
||||
x: params.x !== undefined ? Number(params.x) : null,
|
||||
y: params.y !== undefined ? Number(params.y) : null,
|
||||
scale: params.scale !== undefined ? Number(params.scale) : 1.0,
|
||||
visible_seal: params.visibleSeal !== undefined ? Boolean(params.visibleSeal) : true,
|
||||
seal_theme_id: params.sealThemeId || params.sealTheme || null,
|
||||
seal_custom: params.sealCustom || null,
|
||||
reason: params.reason || 'Assinatura Digital ICP-Brasil',
|
||||
location: params.location || 'Brasil',
|
||||
cpf_display_mode: params.cpfDisplayMode || null,
|
||||
include_date_time: params.includeDateTime !== undefined ? Boolean(params.includeDateTime) : null,
|
||||
custom_extra_info: params.customExtraInfo || null,
|
||||
nonce: nonce,
|
||||
expires_at: expiresAt
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this._fetch('/ccsp/v1/sign-pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.status === 'signed') {
|
||||
const signedPdfBase64 = resp.signed_pdf;
|
||||
const self = this;
|
||||
|
||||
return {
|
||||
requestId: resp.request_id,
|
||||
status: resp.status,
|
||||
signedPdf: signedPdfBase64,
|
||||
sha256: resp.sha256,
|
||||
signature: resp.signature,
|
||||
certificate: resp.certificate,
|
||||
certificateChain: resp.certificate_chain || [resp.certificate],
|
||||
algorithm: resp.algorithm,
|
||||
subjectName: resp.subject_name || null,
|
||||
subjectCpfCnpj: resp.subject_cpf_cnpj || null,
|
||||
documentName: resp.document_name || payload.document_name,
|
||||
|
||||
/**
|
||||
* Converte o PDF assinado em Blob binário para upload via FormData ou visualização.
|
||||
* @returns {Blob}
|
||||
*/
|
||||
getSignedPdfBlob() {
|
||||
return self._base64ToBlob(signedPdfBase64, 'application/pdf');
|
||||
},
|
||||
|
||||
/**
|
||||
* Retorna os bytes puros do PDF assinado em Uint8Array.
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
getSignedPdfBytes() {
|
||||
return self._base64ToBytes(signedPdfBase64);
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispara o download automático do PDF assinado no navegador web.
|
||||
* @param {string} [filename] Nome do arquivo para download.
|
||||
*/
|
||||
download(filename) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const blob = self._base64ToBlob(signedPdfBase64, 'application/pdf');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || resp.document_name || 'documento_assinado.pdf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this._throwMappedError(resp.error_code, resp.message);
|
||||
} catch (err) {
|
||||
if (err instanceof CyberCoreError) throw err;
|
||||
throw new CyberCoreError('Erro durante a assinatura do documento PDF: ' + err.message, 'SIGN_PDF_ERROR', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assina um documento XML completo no padrão XAdES-BES (ICP-Brasil) ou W3C XMLDSig Enveloped.
|
||||
* O XML pode ser fornecido como string de texto puro ou arquivo/bytes.
|
||||
*
|
||||
* @param {Object} params Parâmetros da assinatura XML.
|
||||
* @param {string|File|Blob|ArrayBuffer} params.xml Conteúdo do XML a ser assinado.
|
||||
* @param {string} params.certificateId Identificador do certificado digital.
|
||||
* @param {string} [params.profile='XADES_BES'] Perfil de assinatura: 'XADES_BES' ou 'XMLDSIG'.
|
||||
* @param {string} [params.targetElementTag] Nome da tag alvo (ex: 'infNFe', 'infCte') ou null para o documento todo.
|
||||
* @param {string} [params.targetElementId] ID do elemento alvo (ex: 'NFe3526...').
|
||||
* @param {string} [params.documentName='documento.xml'] Nome amigável do arquivo XML.
|
||||
* @param {string} [params.algorithm] Algoritmo XMLDSig.
|
||||
* @param {number} [params.expiresInSeconds=180] Tempo de validade da requisição em segundos.
|
||||
* @returns {Promise<Object>} Retorna o XML assinado como string de texto e helpers para Blob.
|
||||
*/
|
||||
async signXmlDocument(params) {
|
||||
if (!params) {
|
||||
throw new CyberCoreError('Parâmetros de assinatura XML são obrigatórios.', 'BAD_REQUEST');
|
||||
}
|
||||
|
||||
const certHint = params.certificateId || params.certificateHint;
|
||||
if (!certHint) {
|
||||
throw new CertificateNotFoundError('É necessário especificar o certificado digital para assinar o XML.');
|
||||
}
|
||||
|
||||
let xmlText = null;
|
||||
let xmlBase64 = null;
|
||||
|
||||
if (typeof params.xml === 'string') {
|
||||
xmlText = params.xml;
|
||||
} else if (params.xml) {
|
||||
const buffer = await this._fileToBuffer(params.xml);
|
||||
xmlBase64 = this._bufferToBase64(buffer);
|
||||
}
|
||||
|
||||
if (!xmlText && !xmlBase64) {
|
||||
throw new CyberCoreError('Nenhum documento XML fornecido para assinatura.', 'BAD_REQUEST');
|
||||
}
|
||||
|
||||
const requestId = this._generateUuid();
|
||||
const nonce = this._generateUuid();
|
||||
const expiresAt = new Date(Date.now() + (params.expiresInSeconds || 180) * 1000).toISOString();
|
||||
|
||||
const payload = {
|
||||
version: '1',
|
||||
request_id: requestId,
|
||||
application_id: this.applicationId,
|
||||
operation: 'sign_xml',
|
||||
xml: xmlText,
|
||||
xml_base64: xmlBase64,
|
||||
certificate_hint: certHint,
|
||||
document_name: params.documentName || params.fileName || (params.xml?.name) || 'documento.xml',
|
||||
profile: params.profile || 'XADES_BES',
|
||||
target_element_tag: params.targetElementTag || null,
|
||||
target_element_id: params.targetElementId || null,
|
||||
algorithm: params.algorithm || null,
|
||||
nonce: nonce,
|
||||
expires_at: expiresAt
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this._fetch('/ccsp/v1/sign-xml', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.status === 'signed') {
|
||||
const signedXmlText = resp.signed_xml;
|
||||
const self = this;
|
||||
|
||||
return {
|
||||
requestId: resp.request_id,
|
||||
status: resp.status,
|
||||
signedXml: signedXmlText,
|
||||
sha256: resp.sha256,
|
||||
signature: resp.signature,
|
||||
certificate: resp.certificate,
|
||||
certificateChain: resp.certificate_chain || [resp.certificate],
|
||||
algorithm: resp.algorithm,
|
||||
subjectName: resp.subject_name || null,
|
||||
subjectCpfCnpj: resp.subject_cpf_cnpj || null,
|
||||
documentName: resp.document_name || payload.document_name,
|
||||
|
||||
/**
|
||||
* Converte o XML assinado em Blob binário para upload.
|
||||
* @returns {Blob}
|
||||
*/
|
||||
getSignedXmlBlob() {
|
||||
return new Blob([signedXmlText], { type: 'application/xml;charset=utf-8' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Retorna os bytes UTF-8 do XML assinado em Uint8Array.
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
getSignedXmlBytes() {
|
||||
return new TextEncoder().encode(signedXmlText);
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispara o download automático do XML assinado no navegador web.
|
||||
* @param {string} [filename]
|
||||
*/
|
||||
download(filename) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const blob = new Blob([signedXmlText], { type: 'application/xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || resp.document_name || 'documento_assinado.xml';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this._throwMappedError(resp.error_code, resp.message);
|
||||
} catch (err) {
|
||||
if (err instanceof CyberCoreError) throw err;
|
||||
throw new CyberCoreError('Erro durante a assinatura do documento XML: ' + err.message, 'SIGN_XML_ERROR', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Realiza login / autenticação segura em sistemas web utilizando Certificado Digital (Challenge-Response ICP-Brasil).
|
||||
* Assina o desafio (challenge) emitido pelo servidor e retorna o certificado e dados decodificados do titular.
|
||||
@@ -421,6 +705,38 @@ export class CyberCoreSigner {
|
||||
throw new Error('Tipo de arquivo não suportado para leitura binária.');
|
||||
}
|
||||
|
||||
_bufferToBase64(buffer) {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(buffer).toString('base64');
|
||||
}
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
_base64ToBytes(base64) {
|
||||
const clean = base64.includes(',') ? base64.split(',')[1] : base64;
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return new Uint8Array(Buffer.from(clean, 'base64'));
|
||||
}
|
||||
const binary = atob(clean);
|
||||
const len = binary.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
_base64ToBlob(base64, mimeType = 'application/octet-stream') {
|
||||
const bytes = this._base64ToBytes(base64);
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
async _calculateSha256(data) {
|
||||
let buffer;
|
||||
if (typeof data === 'string') {
|
||||
@@ -492,6 +808,8 @@ export class CyberCoreSigner {
|
||||
// Expõe globalmente caso seja carregado diretamente via tag <script>
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CyberCoreSigner = CyberCoreSigner;
|
||||
window.PdfStampPicker = PdfStampPicker;
|
||||
window.PdfSealDesigner = PdfSealDesigner;
|
||||
}
|
||||
|
||||
export default CyberCoreSigner;
|
||||
|
||||
Reference in New Issue
Block a user