Appearance
Migração: PDF → HTML/CSS + Puppeteer
Resumo
Substituir o fluxo atual (upload PDF → pdf-lib overlay) por: upload DOCX → backend converte para HTML → usuário seleciona texto e atribui variáveis no HTML → backend substitui variáveis e gera PDF via Puppeteer.
Fase 1: Backend (Node.js)
1.1 Dependências
bash
npm install mammoth puppeteer jsdom dompurify
npm install -D @types/dompurify- mammoth (v1.8+): conversão DOCX → HTML preservando formatação
- puppeteer (v23+): renderização HTML → PDF com Chromium headless
- jsdom + dompurify: sanitização e manipulação do HTML no servidor
1.2 Schema do banco (Template)
Alterações no model/schema existente:
typescript
// ANTES
{
pdfId: string // ← REMOVER
pdfPath: string // ← REMOVER
content: string // ← REMOVER
variablesMapping: VariableMapping[] // ← REMOVER
variables: TemplateVariable[] // mantém
}
// DEPOIS
{
htmlContent: string // ← NOVO: HTML completo com markers <span data-var="...">
variables: TemplateVariable[] // mantém (metadados: type, required, source)
}Migration SQL/Mongo (se aplicável):
- Adicionar coluna/campo
htmlContent: text/string - Remover colunas/campos
pdfId,pdfPath,content,variablesMapping - Templates existentes precisarão ser recriados (não há conversão automática PDF → HTML)
1.3 Endpoint: POST /templates/upload-docx
Substitui POST /templates/upload-pdf
Request:
Content-Type: multipart/form-data
file: <arquivo .docx> (obrigatório, max 10MB)
fileName: string (obrigatório)Validações:
- MIME:
application/vnd.openxmlformats-officedocument.wordprocessingml.document - Extensão:
.docx - Tamanho máximo: 10MB
Response (200):
json
{
"htmlContent": "<h1>Título</h1><p>Conteúdo convertido...</p>",
"fileName": "contrato-servicos.docx"
}Erros:
400: arquivo inválido, não é DOCX, ou excede tamanho422: falha na conversão (DOCX corrompido)500: erro interno
Implementação de referência:
typescript
import mammoth from 'mammoth'
import { JSDOM } from 'jsdom'
import createDOMPurify from 'dompurify'
// Configurar DOMPurify no servidor (não tem DOM nativo)
const window = new JSDOM('').window
const DOMPurify = createDOMPurify(window)
async function convertDocxToHtml(fileBuffer: Buffer): Promise<string> {
// styleMap preserva estilos do DOCX que mammoth ignora por padrão
const options = {
buffer: fileBuffer,
styleMap: [
"p[style-name='Title'] => h1:fresh",
"p[style-name='Heading 1'] => h1:fresh",
"p[style-name='Heading 2'] => h2:fresh",
"p[style-name='Heading 3'] => h3:fresh",
"p[style-name='List Paragraph'] => li:fresh",
"r[style-name='Strong'] => strong",
"r[style-name='Emphasis'] => em",
],
// Preservar imagens embutidas como base64
convertImage: mammoth.images.imgElement(function(image) {
return image.read('base64').then(function(imageBuffer) {
return {
src: `data:${image.contentType};base64,${imageBuffer}`
}
})
})
}
const result = await mammoth.convertToHtml(options)
// Logar warnings de conversão para debug
if (result.messages.length > 0) {
console.warn('Mammoth conversion warnings:', result.messages)
}
// Sanitizar HTML para prevenir XSS
const cleanHtml = DOMPurify.sanitize(result.value, {
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr',
'strong', 'b', 'em', 'i', 'u', 's', 'strike',
'ul', 'ol', 'li',
'table', 'thead', 'tbody', 'tr', 'td', 'th',
'span', 'div', 'a', 'img',
'sub', 'sup', 'blockquote', 'pre', 'code'
],
ALLOWED_ATTR: [
'href', 'target', 'src', 'alt', 'width', 'height',
'class', 'style',
'colspan', 'rowspan',
// Atributos custom para markers de variáveis (usado pelo frontend)
'data-var', 'data-var-id'
]
})
return cleanHtml
}
// Controller/Route handler
export async function uploadDocx(req, res) {
const file = req.file // multer middleware
if (!file) {
return res.status(400).json({ error: 'Arquivo DOCX obrigatório' })
}
const validMime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
if (file.mimetype !== validMime) {
return res.status(400).json({ error: 'Formato inválido. Envie um arquivo .docx' })
}
if (file.size > 10 * 1024 * 1024) {
return res.status(400).json({ error: 'Arquivo excede o tamanho máximo de 10MB' })
}
try {
const htmlContent = await convertDocxToHtml(file.buffer)
const fileName = req.body.fileName || file.originalname
return res.json({ htmlContent, fileName })
} catch (error) {
console.error('Erro na conversão DOCX → HTML:', error)
return res.status(422).json({ error: 'Falha ao converter o documento. Verifique se o arquivo DOCX é válido.' })
}
}Configuração de rota (Express):
typescript
import multer from 'multer'
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }
})
router.post('/templates/upload-docx', upload.single('file'), uploadDocx)1.4 Endpoint: POST /contracts/generate-pdfs
Modifica o endpoint existente para usar HTML + Puppeteer em vez de retornar referências a PDFs estáticos.
Request:
json
{
"templateId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"groups": [
{
"groupId": "group-001",
"groupName": "Grupo Prestador A",
"variableValues": {
"contratante_nome": "Empresa ABC Ltda",
"contratante_cnpj": "12.345.678/0001-90",
"valor_contrato": "R$ 50.000,00"
}
},
{
"groupId": "group-002",
"groupName": "Grupo Prestador B",
"variableValues": {
"contratante_nome": "Empresa ABC Ltda",
"contratante_cnpj": "12.345.678/0001-90",
"valor_contrato": "R$ 30.000,00"
}
}
]
}Response (200):
json
[
{
"groupId": "group-001",
"groupName": "Grupo Prestador A",
"pdfUrl": "https://storage.example.com/contracts/group-001-uuid.pdf"
},
{
"groupId": "group-002",
"groupName": "Grupo Prestador B",
"pdfUrl": "https://storage.example.com/contracts/group-002-uuid.pdf"
}
]Erros:
400: templateId ausente ou groups vazio404: template não encontrado422: template sem htmlContent500: falha na geração do PDF
Implementação de referência:
typescript
import puppeteer, { type Browser } from 'puppeteer'
// ============================================================
// 1. SINGLETON DO BROWSER (reutilizar entre requests)
// ============================================================
let browserInstance: Browser | null = null
async function getBrowser(): Promise<Browser> {
if (!browserInstance || !browserInstance.connected) {
browserInstance = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // Evita crash em containers Docker
'--disable-gpu',
'--font-render-hinting=none', // Melhor renderização de fontes
]
})
}
return browserInstance
}
// Cleanup ao encerrar o processo
process.on('SIGTERM', async () => {
if (browserInstance) await browserInstance.close()
})
// ============================================================
// 2. SUBSTITUIÇÃO DE VARIÁVEIS NO HTML
// ============================================================
function substituteVariables(
htmlContent: string,
variableValues: Record<string, string>
): string {
// Usa regex para substituir os markers sem precisar de DOM parser
// Formato do marker: <span data-var="nome_variavel" data-var-id="uuid" class="variable-marker">texto original</span>
//
// O marker é substituído apenas pelo texto do valor, sem o span wrapper.
// Isso garante que o PDF final não tenha markup de edição.
let result = htmlContent
for (const [varName, value] of Object.entries(variableValues)) {
if (!value) continue
// Regex que captura o span completo com data-var="varName"
// Flags: g (global), s (dotall para . incluir \n)
const regex = new RegExp(
`<span[^>]*data-var="${escapeRegex(varName)}"[^>]*>[^<]*</span>`,
'gs'
)
result = result.replace(regex, escapeHtml(value))
}
return result
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
// ============================================================
// 3. WRAPPER HTML PARA O PUPPETEER
// ============================================================
// O htmlContent do template é apenas o body.
// Este wrapper adiciona a estrutura completa do documento
// com CSS que garante fidelidade ao layout A4.
function wrapHtmlForPdf(bodyContent: string): string {
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<style>
/* Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Página A4 */
@page {
size: A4;
margin: 20mm 15mm 20mm 15mm;
}
body {
font-family: 'Times New Roman', Times, serif;
font-size: 12pt;
line-height: 1.5;
color: #000;
background: #fff;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Tipografia */
h1 { font-size: 24pt; font-weight: bold; margin: 0 0 12pt; }
h2 { font-size: 18pt; font-weight: bold; margin: 0 0 10pt; }
h3 { font-size: 14pt; font-weight: bold; margin: 0 0 8pt; }
h4 { font-size: 12pt; font-weight: bold; margin: 0 0 6pt; }
p { margin: 0 0 6pt; }
/* Listas */
ul, ol { margin: 0 0 6pt 24pt; }
li { margin-bottom: 3pt; }
/* Tabelas */
table {
border-collapse: collapse;
width: 100%;
margin: 6pt 0;
}
td, th {
border: 1px solid #666;
padding: 4pt 8pt;
vertical-align: top;
}
th {
background: #f0f0f0;
font-weight: bold;
}
/* Texto formatado */
strong, b { font-weight: bold; }
em, i { font-style: italic; }
u { text-decoration: underline; }
/* Imagens */
img {
max-width: 100%;
height: auto;
}
/* Quebra de página */
.page-break {
page-break-after: always;
}
</style>
</head>
<body>
${bodyContent}
</body>
</html>`
}
// ============================================================
// 4. GERAÇÃO DO PDF COM PUPPETEER
// ============================================================
async function generatePdfBuffer(htmlContent: string): Promise<Buffer> {
const browser = await getBrowser()
const page = await browser.newPage()
try {
const fullHtml = wrapHtmlForPdf(htmlContent)
await page.setContent(fullHtml, {
waitUntil: 'networkidle0', // Aguarda imagens base64 carregarem
timeout: 30000
})
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
margin: {
top: '20mm',
bottom: '20mm',
left: '15mm',
right: '15mm'
}
})
return Buffer.from(pdfBuffer)
} finally {
await page.close() // Sempre fechar a page, mesmo em caso de erro
}
}
// ============================================================
// 5. CONTROLLER / ROUTE HANDLER
// ============================================================
export async function generateContractPdfs(req, res) {
const { templateId, groups } = req.body
// Validações
if (!templateId) {
return res.status(400).json({ error: 'templateId é obrigatório' })
}
if (!groups || !Array.isArray(groups) || groups.length === 0) {
return res.status(400).json({ error: 'groups deve ser um array com pelo menos 1 item' })
}
// Buscar template
const template = await TemplateModel.findById(templateId)
if (!template) {
return res.status(404).json({ error: 'Template não encontrado' })
}
if (!template.htmlContent) {
return res.status(422).json({ error: 'Template não possui conteúdo HTML' })
}
try {
// Gerar PDFs em paralelo (limitado a 3 concurrent para não sobrecarregar)
const results = []
for (const group of groups) {
const { groupId, groupName, variableValues } = group
// 1. Substituir variáveis
const filledHtml = substituteVariables(template.htmlContent, variableValues || {})
// 2. Gerar PDF
const pdfBuffer = await generatePdfBuffer(filledHtml)
// 3. Fazer upload do PDF para storage (S3, GCS, etc.)
const fileName = `contracts/${templateId}/${groupId}-${Date.now()}.pdf`
const pdfUrl = await uploadToStorage(pdfBuffer, fileName, 'application/pdf')
// ↑ uploadToStorage é a função já existente no seu backend
results.push({ groupId, groupName, pdfUrl })
}
return res.json(results)
} catch (error) {
console.error('Erro ao gerar PDFs:', error)
return res.status(500).json({ error: 'Falha ao gerar os PDFs do contrato' })
}
}Configuração de rota:
typescript
router.post('/contracts/generate-pdfs', generateContractPdfs)1.5 Endpoints a remover
GET /templates/recent-pdfs: não há mais PDFs recentes (fluxo mudou para DOCX)POST /templates/upload-pdf: substituído porupload-docx
1.6 Endpoints a modificar
POST /templates e PUT /templates/:id
O payload agora envia htmlContent em vez de pdfId:
typescript
// ANTES
{
name: string
description?: string
pdfId: string // ← REMOVER
variables?: TemplateVariable[]
variablesMapping?: VariableMapping[] // ← REMOVER
status?: TemplateStatus
}
// DEPOIS
{
name: string
description?: string
htmlContent: string // ← NOVO
variables?: TemplateVariable[]
status?: TemplateStatus
}GET /templates/:id
O response agora retorna htmlContent em vez de pdfId/pdfPath:
typescript
// ANTES
{
id, name, description,
pdfId: string, // ← REMOVER
pdfPath: string, // ← REMOVER
variables, variablesMapping, status
}
// DEPOIS
{
id, name, description,
htmlContent: string, // ← NOVO
variables, status
}1.7 Considerações de deploy
Docker / Puppeteer: Puppeteer precisa de Chromium. Em containers Docker, adicionar:
dockerfile
# Instalar dependências do Chromium
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
fonts-noto-cjk \
libatk-bridge2.0-0 \
libdrm2 \
libxss1 \
libxtst6 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Usar Chromium do sistema (evita download automático)
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromiumFontes customizadas: Se os contratos usam fontes específicas (Arial, Calibri, etc.), instalar no container:
dockerfile
# Fontes Microsoft (Arial, Times, Courier, etc.)
RUN apt-get update && apt-get install -y fonts-liberation ttf-mscorefonts-installerMemória:
- Cada page do Puppeteer consome ~50-100MB de RAM
- O singleton do browser reutiliza o processo Chromium
- Em alto volume, considerar pool de browsers ou fila de jobs
Performance:
- Conversão DOCX → HTML: ~100-500ms
- Geração PDF via Puppeteer: ~1-3s por documento
- Para contratos com muitos grupos, os PDFs são gerados sequencialmente para não exceder a memória
Fase 2: Types (Frontend) (JÁ IMPLEMENTADO)
Arquivo: src/domain/template/types.ts
Removido: NormalizedPosition, VariableMapping
Adicionado:
typescript
export type UploadDocxResponse = {
htmlContent: string
fileName: string
}Template modificado: htmlContent: string em vez de pdfPath/pdfId/variablesMapping
Fase 3: Services (Frontend) (JÁ IMPLEMENTADO)
uploadDocxServicesubstituiuploadTemplatePdfServicegetRecentPdfsServiceremovido
Fase 4: Store (JÁ IMPLEMENTADO)
htmlContentref substituipdfFile,pdfUrl,pdfId,pdfPath,totalPages,variableMappingsaddVariable()recebe(variableName, originalText, markerId)e insere marker no HTMLremoveVariableMarker()remove span do HTML via DOMParser
Fase 5: Composable (JÁ IMPLEMENTADO)
useHtmlEditor.tscriado com seleção de texto, inserção/remoção de markers, zoomusePdfEditor.tsdeletado
Fase 6: Componentes (JÁ IMPLEMENTADO)
DocxUploader.vuecriadoHtmlEditor.vuecriadoEditorContainer.vueatualizadoPdfEditor.vue,PdfUploader.vue,RecentPdfList.vuedeletados
Fase 7: Preview no contrato (JÁ IMPLEMENTADO)
usePdfOverlay.tsagora faz substituição de variáveis via DOMParser no frontend (preview instantâneo)ContractTemplateDetail.vuerenderiza preview HTML comv-html- VuePDF e pdf-lib removidos do fluxo
Fase 8: Mocks (JÁ IMPLEMENTADO)
- Mock data usa
htmlContentcom<span data-var="..." class="variable-marker">markers - Handler
upload-docxadicionado - Handler
recent-pdfsremovido
Formato dos markers de variáveis no HTML
O frontend insere variáveis no HTML como spans com data attributes:
html
<span data-var="nome_completo" data-var-id="var_nome_completo_1706234567" class="variable-marker">
João da Silva
</span>data-var: nome da variável (chave para substituição)data-var-id: ID único do marker (para remoção individual)class="variable-marker": classe CSS para highlight visual no editor- Conteúdo do span: texto original selecionado pelo usuário
Na geração do PDF, o backend substitui o span inteiro pelo valor da variável (texto puro).
Verificação
Frontend
- [ ] Upload de DOCX → HTML renderizado corretamente no editor
- [ ] Selecionar texto → atribuir variável → span marcado no HTML
- [ ] Salvar template → htmlContent com markers persistido
- [ ] Carregar template existente → markers visíveis no editor
- [ ] No contrato: atribuir valores → preview com variáveis substituídas
Backend
- [ ]
POST /templates/upload-docx→ converte DOCX para HTML limpo - [ ]
POST /templates→ salva htmlContent no banco - [ ]
GET /templates/:id→ retorna htmlContent - [ ]
POST /contracts/generate-pdfs→ substitui variáveis + gera PDF fiel ao layout - [ ] PDF gerado com formatação preservada (títulos, negrito, tabelas, listas)
- [ ] PDF gerado com margens A4 corretas