diff --git a/src/server/services/PdfService copy 2.ts b/src/server/services/PdfService copy 2.ts new file mode 100644 index 0000000..cf4e73e --- /dev/null +++ b/src/server/services/PdfService copy 2.ts @@ -0,0 +1,634 @@ +import puppeteer from 'puppeteer'; +import { prisma } from '../prismaClient'; +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Serviço para geração e manipulação de arquivos PDF + */ +export class PdfService { + /** + * Cria um documento PDF básico + * @returns Documento PDF + */ + async criarDocumento() { + return await PDFDocument.create(); + } + + /** + * Carrega um documento PDF existente + * @param pdfBytes - Bytes do PDF + * @returns Documento PDF + */ + async carregarDocumento(pdfBytes: Uint8Array) { + return await PDFDocument.load(pdfBytes); + } + + /** + * Adiciona uma página ao documento + * @param doc - Documento PDF + * @param tamanho - Tamanho da página (padrão: A4) + * @returns Página adicionada + */ + adicionarPagina(doc: any, tamanho = [595.28, 841.89]) { + return doc.addPage(tamanho); + } + + /** + * Salva o documento como buffer + * @param doc - Documento PDF + * @returns Buffer do PDF + */ + async salvarComoBuffer(doc: any): Promise { + const pdfBytes = await doc.save(); + return Buffer.from(pdfBytes); + } + + /** + * Carrega uma fonte padrão + * @param doc - Documento PDF + * @param fonte - Nome da fonte padrão + * @returns Fonte carregada + */ + async carregarFonte(doc: any, fonte: StandardFonts) { + return await doc.embedFont(fonte); + } + + /** + * Desenha texto em uma página + * @param pagina - Página do PDF + * @param texto - Texto a ser desenhado + * @param x - Posição X + * @param y - Posição Y + * @param fonte - Fonte a ser usada + * @param tamanho - Tamanho da fonte + * @param cor - Cor do texto (padrão: preto) + */ + desenharTexto( + pagina: any, + texto: string, + x: number, + y: number, + fonte: any, + tamanho: number, + cor = rgb(0, 0, 0) + ) { + pagina.drawText(texto, { + x, + y, + font: fonte, + size: tamanho, + color: cor, + }); + } + + /** + * Cria um cabeçalho para o documento + * @param pagina - Página do PDF + * @param titulo - Título do cabeçalho + * @param fonte - Fonte a ser usada + * @param margemSuperior - Margem superior + * @param margem - Margem lateral + * @returns Posição Y após o cabeçalho + */ + criarCabecalho( + pagina: any, + titulo: string, + fonte: any, + margemSuperior: number, + margem: number + ): number { + const yPos = pagina.getHeight() - margemSuperior; + + this.desenharTexto( + pagina, + titulo, + margem, + yPos, + fonte, + 18, + rgb(0, 0, 0.7) + ); + + // Linha separadora + pagina.drawLine({ + start: { x: margem, y: yPos - 10 }, + end: { x: pagina.getWidth() - margem, y: yPos - 10 }, + thickness: 1, + color: rgb(0, 0, 0.5), + }); + + return yPos - 30; // Retorna a posição Y após o cabeçalho + } + + /** + * Cria um rodapé para o documento + * @param pagina - Página do PDF + * @param texto - Texto do rodapé + * @param fonte - Fonte a ser usada + * @param margem - Margem + */ + criarRodape(pagina: any, texto: string, fonte: any, margem: number) { + const yPos = margem / 2; + + this.desenharTexto( + pagina, + texto, + margem, + yPos, + fonte, + 8, + rgb(0.5, 0.5, 0.5) + ); + } + + private async getConfiguracao() { + try { + // Buscar configuração ou usar valores padrão + const config = await prisma.configuracaoDocumento.findFirst({ + orderBy: { criadoEm: 'desc' }, + select: { + id: true, + timbradoUrl: true, + timbradoMarginTop: true, + timbradoMarginBottom: true, + timbradoMarginLeft: true, + timbradoMarginRight: true, + templatePadrao: true, + margensTemplate: true, + criadoEm: true + } + }); + + console.log('Configuração encontrada:', config); // Debug + + // Retornar configuração padrão se não existir + return { + // Valores padrão + cabecalhoHtml: ` +
+

{{escritorio.nome}}

+
+ `, + rodapeHtml: ` +
+ {{escritorio.endereco}}
+ Tel: {{escritorio.telefone}} | Email: {{escritorio.email}} +
+ `, + // Campos que existem no modelo + timbradoUrl: config?.timbradoUrl || '', + timbradoMarginTop: config?.timbradoMarginTop || 100, + timbradoMarginBottom: config?.timbradoMarginBottom || 50, + timbradoMarginLeft: config?.timbradoMarginLeft || 50, + timbradoMarginRight: config?.timbradoMarginRight || 50, + templatePadrao: config?.templatePadrao || null, + margensTemplate: config?.margensTemplate || null + }; + } catch (error) { + console.error('Erro ao buscar configuração:', error); + // Retornar configuração padrão para não interromper o fluxo + return { + cabecalhoHtml: `

Documento

`, + rodapeHtml: `
Documento gerado pelo sistema
`, + timbradoUrl: '', + timbradoMarginTop: 100, + timbradoMarginBottom: 50, + timbradoMarginLeft: 50, + timbradoMarginRight: 50, + templatePadrao: null, + margensTemplate: null + }; + } + } + + /** + * Gera um PDF a partir do conteúdo HTML simples (apenas texto) e dados do escritório, + * encaixando o texto sobre o timbrado (PDF de fundo) em todas as páginas. + * Não usa Puppeteer, apenas PDF-LIB. + */ + async gerarPDF(conteudo: string, escritorio: any = {}): Promise { + try { + console.log('Buscando configurações do documento...'); + const config = await this.getConfiguracao(); + + // Verificar se temos um timbrado para usar + const temTimbrado = !!config.timbradoUrl; + let timbradoPath = ''; + let timbradoExists = false; + + if (temTimbrado) { + if (config.timbradoUrl.startsWith('/uploads')) { + timbradoPath = path.join(process.cwd(), 'public', config.timbradoUrl); + } else { + timbradoPath = path.join(process.cwd(), 'public', 'uploads', path.basename(config.timbradoUrl)); + } + try { + await fs.access(timbradoPath); + timbradoExists = true; + } catch (e) { + timbradoExists = false; + } + } + + // Margens + const margens = config.margensTemplate || { + top: config.timbradoMarginTop || 100, + left: config.timbradoMarginLeft || 50, + right: config.timbradoMarginRight || 50, + bottom: config.timbradoMarginBottom || 50 + }; + + // Parâmetros de página + const PAGE_WIDTH = 595.28; // A4 + const PAGE_HEIGHT = 841.89; // A4 + const FONT_SIZE = 12; + const LINE_HEIGHT = FONT_SIZE * 1.5; + const MAX_WIDTH = PAGE_WIDTH - margens.left - margens.right; + + // Crie o PDFDocument ANTES de tentar usar pdfDoc para embedFont! + let pdfDoc: PDFDocument = await PDFDocument.create(); + + // Fonte padrão do pdf-lib: TimesRoman, TimesBold, TimesItalic + const font = await pdfDoc.embedFont('Times-Roman'); + const fontBold = await pdfDoc.embedFont('Times-Bold'); + const fontItalic = await pdfDoc.embedFont('Times-Italic'); + const fontBoldItalic = fontBold; + + // Função para decodificar entidades HTML comuns (acentuação, símbolos, º, etc) + function decodeHtmlEntities(str: string): string { + if (!str) return ''; + return str + // Acentos minúsculos e maiúsculos + .replace(/Á/g, 'Á').replace(/á/g, 'á') + .replace(/É/g, 'É').replace(/é/g, 'é') + .replace(/Í/g, 'Í').replace(/í/g, 'í') + .replace(/Ó/g, 'Ó').replace(/ó/g, 'ó') + .replace(/Ú/g, 'Ú').replace(/ú/g, 'ú') + .replace(/À/g, 'À').replace(/à/g, 'à') + .replace(/È/g, 'È').replace(/è/g, 'è') + .replace(/Ì/g, 'Ì').replace(/ì/g, 'ì') + .replace(/Ò/g, 'Ò').replace(/ò/g, 'ò') + .replace(/Ù/g, 'Ù').replace(/ù/g, 'ù') + .replace(/Â/g, 'Â').replace(/â/g, 'â') + .replace(/Ê/g, 'Ê').replace(/ê/g, 'ê') + .replace(/Î/g, 'Î').replace(/î/g, 'î') + .replace(/Ô/g, 'Ô').replace(/ô/g, 'ô') + .replace(/Û/g, 'Û').replace(/û/g, 'û') + .replace(/Ã/g, 'Ã').replace(/ã/g, 'ã') + .replace(/Õ/g, 'Õ').replace(/õ/g, 'õ') + .replace(/Ç/g, 'Ç').replace(/ç/g, 'ç') + .replace(/Ñ/g, 'Ñ').replace(/ñ/g, 'ñ') + .replace(/Ü/g, 'Ü').replace(/ü/g, 'ü') + // Símbolos comuns + .replace(/º/gi, 'º') + .replace(/ª/gi, 'ª') + .replace(/§/gi, '§') + .replace(/°/gi, '°') + .replace(/©/gi, '©') + .replace(/®/gi, '®') + .replace(/¶/gi, '¶') + .replace(/•/gi, '•') + .replace(/–/gi, '–') + .replace(/—/gi, '—') + .replace(/«/gi, '«') + .replace(/»/gi, '»') + .replace(/‘/gi, '‘') + .replace(/’/gi, '’') + .replace(/“/gi, '“') + .replace(/”/gi, '”') + .replace(/€/gi, '€') + .replace(/¥/gi, '¥') + .replace(/£/gi, '£') + .replace(/¢/gi, '¢') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/</gi, '<') + .replace(/>/gi, '>') + // Numéricos + .replace(/&#(\d+);/g, (m, code) => String.fromCharCode(code)); + } + + // Função para remover todas as tags HTML, exceto as de formatação suportadas + function stripUnsupportedTags(html: string): string { + // Remove todas as tags, exceto , , , , ,
,

, , + // Remove atributos de exceto text-indent e text-align + return html + // Mantém apenas com text-indent ou text-align:center + .replace(/])*?>/gi, '') + // Remove atributos de , , , , ,

+ .replace(/<(b|strong|i|em|u|p)[^>]*>/gi, '<$1>') + // Remove todas as outras tags e atributos + .replace(/<(?!\/?(b|strong|i|em|u|br|p|span)[ >])[^>]+>/gi, '') + // Remove spans sem text-indent ou text-align + .replace(/(.*?)<\/span>/gi, '$1') + // Remove comentários HTML + .replace(//g, ''); + } + + // Função para renderizar texto com formatação inline (negrito, itálico, sublinhado) e controle de quebra de linha e recuo + function renderInlineFormattedParagraph( + page, + text: string, + x: number, + y: number, + font, + fontBold, + fontItalic, + fontBoldItalic, + fontSize: number, + color, + align: string, + maxWidth: number, + indent: boolean, + topMargin: number, + bottomMargin: number, + pageHeight: number + ) { + let regex = /(<\/?(b|strong|i|em|u)>)/gi; + let tokens: { text: string, bold: boolean, italic: boolean, underline: boolean }[] = []; + let lastIndex = 0; + let match; + let currentStyle = { bold: false, italic: false, underline: false }; + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + tokens.push({ + text: text.substring(lastIndex, match.index), + bold: currentStyle.bold, + italic: currentStyle.italic, + underline: currentStyle.underline + }); + } + if (/^<(b|strong)>$/i.test(match[0])) currentStyle.bold = true; + if (/^<\/(b|strong)>$/i.test(match[0])) currentStyle.bold = false; + if (/^<(i|em)>$/i.test(match[0])) currentStyle.italic = true; + if (/^<\/(i|em)>$/i.test(match[0])) currentStyle.italic = false; + if (/^$/i.test(match[0])) currentStyle.underline = true; + if (/^<\/u>$/i.test(match[0])) currentStyle.underline = false; + lastIndex = regex.lastIndex; + } + if (lastIndex < text.length) { + tokens.push({ + text: text.substring(lastIndex), + bold: currentStyle.bold, + italic: currentStyle.italic, + underline: currentStyle.underline + }); + } + + let words: { text: string, font: any, underline: boolean, bold: boolean, italic: boolean }[] = []; + for (const token of tokens) { + let usedFont = font; + if (token.bold && token.italic) usedFont = fontBoldItalic; + else if (token.bold) usedFont = fontBold; + else if (token.italic) usedFont = fontItalic; + // Corrige: split por espaço, mas preserva espaços como tokens separados + const split = token.text.split(/(\s+)/); + for (const part of split) { + words.push({ text: part, font: usedFont, underline: token.underline, bold: token.bold, italic: token.italic }); + } + } + + let lines: { tokens: { text: string, font: any, underline: boolean, bold: boolean, italic: boolean }[] }[] = []; + let currentTokens: { text: string, font: any, underline: boolean, bold: boolean, italic: boolean }[] = []; + let currentWidth = 0; + + for (const [i, word] of words.entries()) { + const wordWidth = word.font.widthOfTextAtSize(word.text, fontSize); + if (currentWidth + wordWidth > maxWidth && currentTokens.length > 0) { + lines.push({ tokens: currentTokens }); + currentTokens = [word]; + currentWidth = wordWidth; + } else { + currentTokens.push(word); + currentWidth += wordWidth; + } + } + if (currentTokens.length > 0) { + lines.push({ tokens: currentTokens }); + } + + // Ajuste: Força alinhamento consistente para todas as linhas do parágrafo + // Se align === 'center', todas as linhas centralizadas + // Se align === 'justify', todas as linhas justificadas (exceto a última ou linhas curtas) + // Se align === 'left' ou outro, todas à esquerda + + for (let i = 0; i < lines.length; i++) { + if (y < bottomMargin + fontSize * 1.5) { + return null; + } + let line = lines[i]; + let drawX = x; + + // Remove espaços no início e fim da linha para não justificar esses espaços + let trimmedTokens = [...line.tokens]; + while (trimmedTokens.length && /^\s+$/.test(trimmedTokens[0].text)) trimmedTokens.shift(); + while (trimmedTokens.length && /^\s+$/.test(trimmedTokens[trimmedTokens.length - 1].text)) trimmedTokens.pop(); + + let totalWidth = trimmedTokens.reduce((acc, t) => acc + t.font.widthOfTextAtSize(t.text, fontSize), 0); + + // Corrige centralização: centraliza a linha inteira, inclusive se for a única linha do parágrafo + if (align === 'center') { + drawX = x + (maxWidth - totalWidth) / 2; + for (const t of trimmedTokens) { + page.drawText(t.text, { + x: drawX, + y, + size: fontSize, + font: t.font, + color + }); + if (t.underline && t.text.trim() !== '') { + const underlineY = y - 1.5; + page.drawLine({ + start: { x: drawX, y: underlineY }, + end: { x: drawX + t.font.widthOfTextAtSize(t.text, fontSize), y: underlineY }, + thickness: 0.75, + color + }); + } + drawX += t.font.widthOfTextAtSize(t.text, fontSize); + } + } else if (align === 'justify' && i < lines.length - 1 && trimmedTokens.length > 2) { + // Justify: distribui espaço entre palavras, exceto na última linha ou linhas curtas + const spaces = trimmedTokens.filter(t => /^\s+$/.test(t.text)); + const numSpaces = spaces.length; + const lineTextWidth = trimmedTokens.reduce((acc, t) => acc + t.font.widthOfTextAtSize(t.text, fontSize), 0); + const extraSpace = maxWidth - lineTextWidth; + let spaceWidth = 0; + // Só justifica se houver pelo menos 2 espaços e a linha não for muito curta + if (numSpaces > 0 && extraSpace > 0) { + spaceWidth = extraSpace / numSpaces; + } + let localDrawX = x; + for (const t of trimmedTokens) { + page.drawText(t.text, { + x: localDrawX, + y, + size: fontSize, + font: t.font, + color + }); + if (t.underline && t.text.trim() !== '') { + const underlineY = y - 1.5; + page.drawLine({ + start: { x: localDrawX, y: underlineY }, + end: { x: localDrawX + t.font.widthOfTextAtSize(t.text, fontSize), y: underlineY }, + thickness: 0.75, + color + }); + } + if (/^\s+$/.test(t.text) && spaceWidth > 0) { + localDrawX += t.font.widthOfTextAtSize(t.text, fontSize) + spaceWidth; + } else { + localDrawX += t.font.widthOfTextAtSize(t.text, fontSize); + } + } + } else { + // Alinhamento à esquerda (padrão) ou última linha de parágrafo justificado + for (const t of trimmedTokens) { + page.drawText(t.text, { + x: drawX, + y, + size: fontSize, + font: t.font, + color + }); + if (t.underline && t.text.trim() !== '') { + const underlineY = y - 1.5; + page.drawLine({ + start: { x: drawX, y: underlineY }, + end: { x: drawX + t.font.widthOfTextAtSize(t.text, fontSize), y: underlineY }, + thickness: 0.75, + color + }); + } + drawX += t.font.widthOfTextAtSize(t.text, fontSize); + } + } + y -= fontSize * 1.5; + } + return y; + } + + const texto = decodeHtmlEntities( + stripUnsupportedTags( + this.processarTemplate(conteudo, escritorio) + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/]*>/gi, '') + .replace(/]*text-indent[^>]*>/gi, '§§INDENT§§') + .replace(/]*text-align\s*:\s*center[^>]*>/gi, '§§CENTER§§') + .replace(/<\/span>/gi, '') + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + ) + ); + + const paragrafos = texto.split(/\n{2,}/).map(p => p.trim()).filter(Boolean); + + let timbradoPdf: PDFDocument | null = null; + let timbradoPageCount = 0; + let timbradoPages: any[] = []; + if (timbradoExists) { + const timbradoBytes = await fs.readFile(timbradoPath); + timbradoPdf = await PDFDocument.load(timbradoBytes); + timbradoPageCount = timbradoPdf.getPageCount(); + timbradoPages = []; + for (let i = 0; i < timbradoPageCount; i++) { + const [embedded] = await pdfDoc.embedPages([timbradoPdf.getPage(i)]); + timbradoPages.push(embedded); + } + } + + let currentPar = 0; + let pageIndex = 0; + while (currentPar < paragrafos.length) { + let page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); + if (timbradoPages.length > 0) { + const embedded = timbradoPages[pageIndex % timbradoPages.length]; + page.drawPage(embedded, { + x: 0, + y: 0, + width: PAGE_WIDTH, + height: PAGE_HEIGHT + }); + } + let y = PAGE_HEIGHT - margens.top; + while (currentPar < paragrafos.length) { + let indent = paragrafos[currentPar].startsWith('§§INDENT§§'); + let center = paragrafos[currentPar].startsWith('§§CENTER§§'); + let align: 'center' | 'justify' | 'left' = center ? 'center' : 'justify'; + let text = paragrafos[currentPar].replace(/^§§INDENT§§/, '').replace(/^§§CENTER§§/, '').trim(); + + // Se o parágrafo for centralizado, força alinhamento center e ignora recuo + if (center) { + indent = false; + align = 'center'; + } + + text = decodeHtmlEntities(text); + + let x = margens.left; + let parWidth = MAX_WIDTH; + + let yAfter = renderInlineFormattedParagraph( + page, + text, + x, + y, + font, + fontBold, + fontItalic, + fontBoldItalic, + FONT_SIZE, + rgb(0, 0, 0), + align, + parWidth, + indent, + margens.top, + margens.bottom, + PAGE_HEIGHT + ); + if (yAfter === null) { + pageIndex++; + break; + } + y = yAfter - FONT_SIZE * 0.5; + currentPar++; + if (y < margens.bottom + FONT_SIZE * 1.5) break; + } + pageIndex++; + } + + const pdfBytes = await pdfDoc.save(); + return Buffer.from(pdfBytes); + } catch (error) { + console.error('Erro geral ao gerar PDF:', error); + throw error; + } + } + + private processarTemplate(template: string, dados: any): string { + return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { + const keyParts = key.trim().split('.'); + let value = dados; + + for (const part of keyParts) { + if (value && typeof value === 'object' && part in value) { + value = value[part]; + } else { + value = undefined; + break; + } + } + + return value !== undefined ? value : match; + }); + } +} diff --git a/src/server/services/PdfService.ts b/src/server/services/PdfService.ts index 04bbe04..a73ebde 100644 --- a/src/server/services/PdfService.ts +++ b/src/server/services/PdfService.ts @@ -294,67 +294,58 @@ export class PdfService { function decodeHtmlEntities(str: string): string { if (!str) return ''; - // Usa o método nativo do DOM para decodificar entidades HTML - const textarea = document.createElement('textarea'); - textarea.innerHTML = str; - let decoded = textarea.value; - - // Fallback para ambientes sem DOM (Node.js) - if (decoded === str) { - return str - // Acentos minúsculos e maiúsculos - .replace(/Á/g, 'Á').replace(/á/g, 'á') - .replace(/É/g, 'É').replace(/é/g, 'é') - .replace(/Í/g, 'Í').replace(/í/g, 'í') - .replace(/Ó/g, 'Ó').replace(/ó/g, 'ó') - .replace(/Ú/g, 'Ú').replace(/ú/g, 'ú') - .replace(/À/g, 'À').replace(/à/g, 'à') - .replace(/È/g, 'È').replace(/è/g, 'è') - .replace(/Ì/g, 'Ì').replace(/ì/g, 'ì') - .replace(/Ò/g, 'Ò').replace(/ò/g, 'ò') - .replace(/Ù/g, 'Ù').replace(/ù/g, 'ù') - .replace(/Â/g, 'Â').replace(/â/g, 'â') - .replace(/Ê/g, 'Ê').replace(/ê/g, 'ê') - .replace(/Î/g, 'Î').replace(/î/g, 'î') - .replace(/Ô/g, 'Ô').replace(/ô/g, 'ô') - .replace(/Û/g, 'Û').replace(/û/g, 'û') - .replace(/Ã/g, 'Ã').replace(/ã/g, 'ã') - .replace(/Õ/g, 'Õ').replace(/õ/g, 'õ') - .replace(/Ç/g, 'Ç').replace(/ç/g, 'ç') - .replace(/Ñ/g, 'Ñ').replace(/ñ/g, 'ñ') - .replace(/Ü/g, 'Ü').replace(/ü/g, 'ü') - // Símbolos comuns - .replace(/º/gi, 'º') - .replace(/ª/gi, 'ª') - .replace(/§/gi, '§') - .replace(/°/gi, '°') - .replace(/©/gi, '©') - .replace(/®/gi, '®') - .replace(/¶/gi, '¶') - .replace(/•/gi, '•') - .replace(/–/gi, '–') - .replace(/—/gi, '—') - .replace(/«/gi, '«') - .replace(/»/gi, '»') - .replace(/‘/gi, ''') - .replace(/’/gi, ''') - .replace(/“/gi, '"') - .replace(/”/gi, '"') - .replace(/€/gi, '€') - .replace(/¥/gi, '¥') - .replace(/£/gi, '£') - .replace(/¢/gi, '¢') - .replace(/ /gi, ' ') - .replace(/&/gi, '&') - .replace(/"/gi, '"') - .replace(/'/g, "'") - .replace(/</gi, '<') - .replace(/>/gi, '>') - // Numéricos - .replace(/&#(\d+);/g, (m, code) => String.fromCharCode(code)); - } - - return decoded; + // Como estamos em Node.js, usamos apenas o método de substituição + return str + // Acentos minúsculos e maiúsculos + .replace(/Á/g, 'Á').replace(/á/g, 'á') + .replace(/É/g, 'É').replace(/é/g, 'é') + .replace(/Í/g, 'Í').replace(/í/g, 'í') + .replace(/Ó/g, 'Ó').replace(/ó/g, 'ó') + .replace(/Ú/g, 'Ú').replace(/ú/g, 'ú') + .replace(/À/g, 'À').replace(/à/g, 'à') + .replace(/È/g, 'È').replace(/è/g, 'è') + .replace(/Ì/g, 'Ì').replace(/ì/g, 'ì') + .replace(/Ò/g, 'Ò').replace(/ò/g, 'ò') + .replace(/Ù/g, 'Ù').replace(/ù/g, 'ù') + .replace(/Â/g, 'Â').replace(/â/g, 'â') + .replace(/Ê/g, 'Ê').replace(/ê/g, 'ê') + .replace(/Î/g, 'Î').replace(/î/g, 'î') + .replace(/Ô/g, 'Ô').replace(/ô/g, 'ô') + .replace(/Û/g, 'Û').replace(/û/g, 'û') + .replace(/Ã/g, 'Ã').replace(/ã/g, 'ã') + .replace(/Õ/g, 'Õ').replace(/õ/g, 'õ') + .replace(/Ç/g, 'Ç').replace(/ç/g, 'ç') + .replace(/Ñ/g, 'Ñ').replace(/ñ/g, 'ñ') + .replace(/Ü/g, 'Ü').replace(/ü/g, 'ü') + // Símbolos comuns + .replace(/º/gi, 'º') + .replace(/ª/gi, 'ª') + .replace(/§/gi, '§') + .replace(/°/gi, '°') + .replace(/©/gi, '©') + .replace(/®/gi, '®') + .replace(/¶/gi, '¶') + .replace(/•/gi, '•') + .replace(/–/gi, '–') + .replace(/—/gi, '—') + .replace(/«/gi, '«') + .replace(/»/gi, '»') + .replace(/‘/gi, ''') + .replace(/’/gi, ''') + .replace(/“/gi, '"') + .replace(/”/gi, '"') + .replace(/€/gi, '€') + .replace(/¥/gi, '¥') + .replace(/£/gi, '£') + .replace(/¢/gi, '¢') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/</gi, '<') + .replace(/>/gi, '>') + // Numéricos + .replace(/&#(\d+);/g, (m, code) => String.fromCharCode(code)); } const texto = decodeHtmlEntities(