Skip to content

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 tamanho
  • 422: 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 vazio
  • 404: template não encontrado
  • 422: template sem htmlContent
  • 500: 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

// ============================================================
// 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 por upload-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/chromium

Fontes 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-installer

Memó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

  • uploadDocxService substitui uploadTemplatePdfService
  • getRecentPdfsService removido

Fase 4: Store: JÁ IMPLEMENTADO

  • htmlContent ref substitui pdfFile, pdfUrl, pdfId, pdfPath, totalPages, variableMappings
  • addVariable() recebe (variableName, originalText, markerId) e insere marker no HTML
  • removeVariableMarker() remove span do HTML via DOMParser

Fase 5: Composable: JÁ IMPLEMENTADO

  • useHtmlEditor.ts criado com seleção de texto, inserção/remoção de markers, zoom
  • usePdfEditor.ts deletado

Fase 6: Componentes: JÁ IMPLEMENTADO

  • DocxUploader.vue criado
  • HtmlEditor.vue criado
  • EditorContainer.vue atualizado
  • PdfEditor.vue, PdfUploader.vue, RecentPdfList.vue deletados

Fase 7: Preview no contrato: JÁ IMPLEMENTADO

  • usePdfOverlay.ts agora faz substituição de variáveis via DOMParser no frontend (preview instantâneo)
  • ContractTemplateDetail.vue renderiza preview HTML com v-html
  • VuePDF e pdf-lib removidos do fluxo

Fase 8: Mocks: JÁ IMPLEMENTADO

  • Mock data usa htmlContent com <span data-var="..." class="variable-marker"> markers
  • Handler upload-docx adicionado
  • Handler recent-pdfs removido

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